diff --git a/.gitignore b/.gitignore index c43c326..c771de5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .gradle/ /build .idea +.aiassistant/rules/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c470d45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,268 @@ +--- +적용: 항상 +--- + +Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) + +# AGENTS.md — 에이전트를 위한 실행 지침서 (booking) + +본 문서는 사람용 README가 아닌, 코드 에이전트(코딩 봇)를 위한 실행 지침서입니다. 이 저장소의 실제 파일과 문서를 스캔해 확인된 사실만 기술합니다. 확인 불가능한 항목은 "확인 불가"로 명시합니다. + +## 1) Title & Scope +- 목적: 이 파일은 에이전트를 위한 지침서입니다. 개발/운영 작업 자동화 시 준수 규칙과 명령을 제공합니다. +- 적용 범위: 단일 레포지토리(single repo), 단일 모듈 구조 + - 근거: `settings.gradle`에 `rootProject.name = 'booking'`만 정의되어 있으며 서브프로젝트 선언 없음. + +## 2) Documentation Reference (docs 우선) +- 모든 작업 전/중에 `./docs` 디렉토리 문서를 우선적으로 참조하십시오. +- 주요 문서: + - 아키텍처 규칙: `docs/specs/policy/application.md` + - 도메인 개요: `docs/specs/domain.md` + - API 명세: `docs/specs/api/*.md` + - 인증/인가 정책 문서 틀: `docs/specs/policy/authentication.md`, `docs/specs/policy/authorization.md` (현재 비어 있음) + - 작업 메모/할 일: `docs/devlog/*`, `docs/todo.md` +- 세부 설계나 정책 충돌 시: `docs/specs/policy/application.md`의 규칙을 최우선으로 따르십시오. + +## 3) Project Setup +- JDK/Gradle/Spring Boot 버전 + - Java: 21 (근거: `build.gradle` → `java.toolchain.languageVersion = 21`) + - Gradle Wrapper: 8.14.3 (근거: `gradle/wrapper/gradle-wrapper.properties` → `distributionUrl`) + - Spring Boot: 3.5.4 (근거: `build.gradle` → `id 'org.springframework.boot' version '3.5.4'`) +- 빌드/의존성 설치 + - 명령: `./gradlew clean build` + - 테스트는 JUnit Platform 사용, 테스트 프로필은 Gradle test 태스크에서 `spring.profiles.active=test`로 설정됨 (근거: `build.gradle` → tasks.named('test')). +- 로컬 실행 + - 명령: `./gradlew bootRun` + - 활성 프로필: 기본 `local` (근거: `src/main/resources/application.yml` → `spring.profiles.active: local`) + - DB 및 JWT 설정은 `application-local.yml` 참고. 민감정보는 환경변수로 덮어쓰기를 권장. +- 테스트 실행 + - 기본: `./gradlew test` (JUnit5, Spring Boot Test, Mockito, Security Test, ArchUnit 포함) + - 테스트 시 프로필: `test` (근거: `build.gradle` test 태스크 설정) +- 코드 분석/품질 도구 + - SpotBugs 사용 (근거: `build.gradle` → `com.github.spotbugs` 플러그인). 일반 태스크: `spotbugsMain`, `spotbugsTest`. + - 포매터/린터(Spotless/Checkstyle 등): 확인 불가 (저장소 내 설정/플러그인 없음). + +## 4) Architecture Rules (준수 규칙) +- 전반: 헥사고날 아키텍처(Ports & Adapters) + - 근거: `docs/specs/policy/application.md` 및 `src/test/java/.../arch/HexagonalArchitectureTest.java` +- 계층 및 패키지 + - domain: `org.mandarin.booking.domain` — 순수 모델/예외/커맨드/요청/응답 (프레임워크 의존 금지) + - app: `org.mandarin.booking.app` — 유스케이스 서비스, 포트(`app/port`), 퍼시스턴스 포트/구현(`app/persist`), AOP 등 + - adapter: `org.mandarin.booking.adapter` — webapi, security 등 외부와의 접점 + - 아키텍처 테스트 강제: `HexagonalArchitectureTest` 층 규칙 참조. +- DDD/엔티티 규칙(요지) + - Aggregate Root 공개 범위 준수, 도메인 엔티티는 domain 패키지에 위치. + - DTO(요청/응답/커맨드)는 현재 domain에 위치하며 컨트롤러에서 변환하여 사용 (근거: policy 문서). +- 영속성(JPA) + - 의존성: `spring-boot-starter-data-jpa`, DB: MySQL(H2 for test), P6Spy (근거: `build.gradle`) + - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `MovieRepository`). + - 트랜잭션 경계는 app 서비스 (예: `MovieCommandRepository`에 `@Transactional`). +- 보안(Spring Security/JWT) + - JWT 파싱/인증: `adapter/security/JwtFilter`가 Authorization 헤더 `Bearer ` 처리 (근거 파일). + - SecurityFilterChain 설정과 우선순위: `SecurityConfig` + - @Order(1) `apiChain` → 직접 구현한 엔드포인트에 대한 인증/인가 설정. `api/**` 규율을 준수함. + - @Order(2) `publicChain` → 그 외 경로 permitAll. + - AuthenticationProvider: `adapter/security/CustomAuthenticationProvider` (구체 로직은 소스 참조). +- 메시징/캐시/검색 + - Kafka/Redis/Elasticsearch 사용: 확인 불가 (관련 의존성/설정 없음). + +자세한 규칙은 `docs/specs/policy/application.md`를 준수하십시오. + +## 5) Code Style & Readability Rules (읽기 좋은 코드 작성 규칙) + +에이전트가 코드를 작성할 때 반드시 준수해야 하는 가독성 중심 규칙입니다. + +5.1 네이밍 +- 클래스/인터페이스: 명확한 도메인 언어 사용 (예: MovieRegisterer, PaymentGatewayClient) +- 메서드: 동사+목적어 (예: registerMovie, validateToken, findMemberByEmail) +- 변수/필드: 축약 금지, 문맥 명확히 (예: memberEmail, jwtTokenSecret) +- 테스트 메서드: 시나리오 기반 네이밍 (예: shouldFailWhenPasswordIsInvalid) + +5.2 OOP 원칙 +- Tell, don’t ask: 데이터를 꺼내 연산하지 말고 객체에게 메시지를 보내라. +- SRP: 클래스는 한 가지 책임만. 메서드는 5~10줄 이내 유지. +- 불변 객체 지향: 값 객체(Value Object)는 final 필드와 팩토리 메서드 사용. +- 의미 있는 도메인 모델: Map 대신 MemberProfile 같은 타입을 정의. + +5.3 글 읽듯이 읽히는 코드 +- 한 메서드는 하나의 이야기(스토리)를 표현해야 함. +- 중첩 if/else 최소화 → 조기 반환(early return) 활용. +- 숫자/문자열 리터럴은 상수화 (MAX_RETRY_COUNT, DATE_FORMAT_PATTERN). +- 체이닝 시 가독성 유지: 각 메서드 호출을 줄바꿈 정렬. + +5.4 주석 규칙 +- 기본 원칙: 코드 자체가 의도를 설명할 수 있도록 작성 → 불필요한 주석 금지. +- 허용되는 주석 위치/용도 +- 인터페이스/추상 클래스: 계약(Contract) 설명 +- 복잡한 알고리즘/비즈니스 규칙: 수학적 공식, 근거 논문, 외부 레퍼런스 출처 표기 +- TODO/FIXME: 보완 필요시 명확한 설명과 함께 작성 +- 금지: “어떻게 동작하는지”를 코드 그대로 설명하는 주석. + +작업 시 반드시 `docs/specs/policy/application.md` 규칙을 따르십시오. + +## 6) Commands (신뢰 가능한 명령만) +- Gradle Wrapper 사용을 강제합니다. + +```bash +# 빌드 +./gradlew clean build + +# 단위/통합 테스트 (test 프로필로 실행) +./gradlew test + +# SpotBugs 정적 분석 +./gradlew spotbugsMain spotbugsTest + +# 로컬 실행 (기본 활성 프로필: local) +./gradlew bootRun + +# Docker로 MySQL 기동 (compose.yaml 기반) +docker compose up -d + +# 통합 테스트만 별도 태깅 없음 → 전체 테스트 수행 +./gradlew test +``` + +근거 파일/경로: +- `build.gradle` (plugins, dependencies, test 태스크) +- `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` +- `compose.yaml` + +## 7) Contribution & PR Rules +- 브랜치/커밋/PR 규약: `.github/pull_request_template.md` 참조 +- 권장 체크리스트(제안): + - [ ] 모든 테스트 통과 (`./gradlew test`) + - [ ] SpotBugs 통과 (`./gradlew spotbugsMain spotbugsTest`) + - [ ] 아키텍처 테스트 통과 (`HexagonalArchitectureTest`) + - [ ] docs/specs/* 업데이트 반영 +- 코드 스타일 점검 + - [ ] 코드가 글 읽듯 자연스럽게 읽히는가? (네이밍, 메서드 길이, 역할 분리 확인) + - [ ] 불필요한 주석이 없는가? 필요한 주석만 인터페이스/복잡 규칙에 존재하는가? + - [ ] 모든 테스트/SpotBugs/아키텍처 테스트 통과 여부 + +## 8) Environments +- 프로필 + - local: 기본 활성 (근거: `application.yml`), MySQL 설정 및 JWT 시크릿 포함 (근거: `application-local.yml`). + - test: H2 메모리 DB, JPA DDL auto create, JWT 설정 포함 (근거: `application-test.yml`). + - prod: 파일 존재하나 내용 비어 있음 → 확인 불가. +- 필수 환경변수(권장 키) + - `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD` + - `JWT_TOKEN_SECRET`, `JWT_TOKEN_ACCESS`, `JWT_TOKEN_REFRESH` + - 위 값은 실제 비밀을 포함하므로, 절대 저장소에 커밋하지 말고 실행 환경에서 주입하십시오. 현재 로컬 yml에는 예시 값이 존재하나, 운영에서는 환경변수로 덮어쓰기를 권장. + +## 9) Limitations / Unknowns +- CI/CD(.github/workflows) 설정: 확인 불가. +- 코드 포매팅(Spotless/Checkstyle): 구성 없음 → 확인 불가. +- 마이그레이션 도구(Flyway/Liquibase): 확인 불가. +- 메시징(Kafka)/캐시(Redis)/검색(Elasticsearch): 사용 근거 없음 → 확인 불가. +- 배포(AWS/K8s): 설정/스크립트 부재 → 확인 불가. + +향후 TODO(사람이 보완): +- prod 프로필 구성 및 비밀 주입 전략 정의 +- CI 파이프라인 도입(.github/workflows) +- DB 마이그레이션 도구 채택 및 규약 수립 +- 인증/인가 정책 문서(`authentication.md`, `authorization.md`) 구체화 +- 코드 포매터 도입 여부 결정(Spotless/Checkstyle 등) + +## 10) Appendix +- 모듈/계층 의존 개요(텍스트) + - adapter(webapi, security) → app\(ports, services, persist adapters) → domain + - domain은 프레임워크 비의존, app은 domain에만 의존, adapter는 app의 포트에 의존 +- 주요 디렉터리 + - `src/main/java/org/mandarin/booking/BookingApplication.java` — Spring Boot 엔트리포인트 + - `src/main/java/org/mandarin/booking/adapter/webapi/*` — REST 컨트롤러/응답 래핑 + - `src/main/java/org/mandarin/booking/adapter/security/*` — 보안 구성/JWT 필터/프로바이더 + - `src/main/java/org/mandarin/booking/app/*` — 서비스, AOP, 포트, 퍼시스트 어댑터 + - `src/main/java/org/mandarin/booking/domain/*` — 도메인 모델과 DTO/커맨드 + - `src/test/java/...` — 단위/아키텍처/통합 테스트 스위트 + +## 11) Test +- TDD 원칙: 새로운 기능은 테스트부터 작성하고, `./gradlew test`로 검증하십시오. +- 테스트 정책 근거: + - JUnit Platform 활성화 및 test 프로필 지정 (근거: `build.gradle` test 태스크) + - 아키텍처 검증: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` + - Web/API 및 보안 단위/통합 테스트 예시: `src/test/java/org/mandarin/booking/webapi/**`, `adapter/security/**` +- 단위 vs 통합 테스트 + - 단위: 도메인/서비스 단위 로직 검증, 외부 의존 모킹(Mock) (예: Mockito) + - 통합: Spring Context 기동, 필터/시큐리티/컨트롤러 경로 포함. 테스트 프로필 `test`와 H2 DB 사용. +- 자세한 내용은 `./docs/specs/policy/test.md`를 참조. +--- + +## 12) Documentation Authoring Rules (문서 작성 규칙) +- 적용 범위: `./docs/specs` 디렉토리의 모든 문서(todo.md 제외). 이 규칙은 문서를 자동으로 작성/갱신하는 에이전트를 위한 것입니다. +- 공통 원칙 + - 사실만 기술하고, 확인 불가한 항목은 반드시 "확인 불가"로 명시합니다. 근거 파일/경로를 문서 내에 링크로 첨부합니다. + - 한국어를 기본으로 작성합니다. 코드/명령/경로는 코드블록으로 표시합니다. + - 변경 시 일관된 섹션 순서와 템플릿을 유지합니다. + - 예시 명령은 복사-붙여넣기 즉시 실행 가능한 상태로 제공합니다. + +- 파일/이름 규칙 + - API 스펙: `docs/specs/api/_.md` 또는 엔드포인트 의미가 드러나는 snake_case 파일명 사용 + - 근거: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + - 정책 문서: `docs/specs/policy/.md` + - 근거: `docs/specs/policy/application.md`, `authentication.md`, `authorization.md`, `test.md` + - 도메인 설계: `docs/specs/domain.md` + +- API 스펙 문서 템플릿 + 1) 제목 생략 가능(현재 파일들은 섹션 위주). 최상단에 섹션 "요청/응답/테스트"를 포함합니다. + 2) 요청 섹션 구성 + - 메서드, 경로, 헤더 코드블록, 본문 JSON 예시(필수 필드 포맷 포함) + - 실행 가능한 curl 예시를 제공 + - 근거 예시: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + 3) 응답 섹션 구성 + - 상태코드 명시, 응답 JSON 예시 제공(필수 필드 포함) + 4) 테스트 섹션 구성 + - 체크박스 형태의 수용 기준 리스트(`[x]/[ ]`) 사용. 실제 테스트 코드와 동기화합니다. + - 체크리스트 항목은 구체적 조건/결과를 포함합니다. + +- 도메인 문서 템플릿 + - 상단 개요(프로젝트/도메인 목적) + - 도메인 모델 섹션 + - 각 Aggregate/Entity 별로: 표제 → 역할 설명(이탤릭), 속성 목록, 행위 목록, 관련 타입 목록 + - 근거 예시: `docs/specs/domain.md` + +- 정책 문서 템플릿 + - 번호 있는 대제목(1., 2., 3. …)을 사용하여 규칙을 체계화 + - 레이어/의존/포트/보안/테스트 등 주제별 세부 항목을 불릿으로 상세화 + - 실제 코드/설정 파일 경로를 근거로 명시 + - "확인 불가"를 명확히 표기해 향후 TODO를 남김 + - 근거 예시: `docs/specs/policy/application.md`, `docs/specs/policy/test.md` + +- 링크/근거 표기 규칙 + - 저장소 상대 경로 링크를 사용합니다(예: `build.gradle`, `src/main/...`). + - 문서 말미 또는 섹션 말미에 "근거" 목록을 배치하여 신뢰 가능한 출처를 나열합니다. + - 정책/가이드 문서에는 규칙 옆에 괄호로 근거를 병기해도 됩니다. + +- 표기/형식 규칙 + - 헤더 레벨은 H2(##)부터 사용해 문서 내 구조를 안정적으로 유지합니다. + - 코드/JSON/명령은 fenced code block 사용. JSON 예시에는 실제 키를 포함하되 민감정보는 예시 값으로 대체. + - 에러/예외/상태코드는 명시적으로 표기(예: `400 Bad Request`, `401 Unauthorized`). + - 체크박스는 `[x]`(충족) / `[ ]`(미충족) 형식으로 유지. + +- 테스트와의 동기화 + - API 문서의 테스트 체크리스트는 실제 테스트(`src/test/java/...`)와 1:1로 대응하도록 작성/갱신합니다. + - 새로운 테스트가 추가되면 해당 API 문서의 체크리스트도 즉시 업데이트합니다. + - 근거: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java`, `adapter/security/*Test.java`, `arch/HexagonalArchitectureTest.java` + +- 프로필/환경 기술 시 유의사항 + - 실제 yml의 키/값/프로필을 그대로 반영하고, 운영 비밀은 "환경변수로 주입"이라고만 적습니다. + - 근거: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` + +- 금지 사항 + - 추측성 서술 금지("추정/아마도" 금지). 알 수 없는 항목은 "확인 불가". + - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. + +- 예시 스니펫 스타일 + - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/movie_register.md`의 curl 예시) + - JSON: 축약하지 말고 필요한 필드를 명시, 포맷은 pretty 또는 단일 라인 일관 유지. + +--- + +근거 스니펫 링크/파일 경로 요약: +- `build.gradle`: 플러그인/의존성/테스트 태스크 +- `settings.gradle`: 단일 모듈 확인 +- `src/main/resources/application*.yml`: 프로필/환경 설정 +- `compose.yaml`: 로컬 환경에서 MySQL 기동 설정 +- `docs/specs/policy/application.md`: 아키텍처 및 작업 규칙 +- `src/main/java/.../SecurityConfig.java`, `JwtFilter.java`: 보안 규칙 +- `src/main/java/.../app/persist/*.java`: 영속성 포트/리포지토리 구조 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8755d68 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# booking — 영화 예매 시스템 (개요 중심 README) + +이 문서는 프로젝트를 어떻게 실행하는지보다는, 이 프로젝트가 무엇인지, 어떤 구성과 아키텍처를 가지는지, 어떤 기술을 왜 선택했는지, 그리고 어떤 방식으로 개발하는지를 설명합니다. 모든 주장은 저장소 내 문서/코드에 대한 링크로 근거를 제시합니다. + +- 저장소 루트: 단일 모듈(Spring Boot) 프로젝트 + +--- + +## 1. 프로젝트 소개 +영화 예매(Booking) 도메인을 다루는 학습 목적의 Spring Boot 애플리케이션입니다. 헥사고날 아키텍처(Ports & Adapters)를 채택하여 도메인 규칙을 프레임워크로부터 분리하고, 보안/웹/영속성 같은 어댑터 계층을 통해 외부 세계와 상호작용합니다. + +- 도메인 개요: [docs/specs/domain.md](docs/specs/domain.md) +- 아키텍처/개발 규칙: [docs/specs/policy/application.md](docs/specs/policy/application.md) +- 테스트 규칙: [docs/specs/policy/test.md](docs/specs/policy/test.md) +--- + +## 2. 핵심 기능 +- 추후 작성 예정 + +테스트로 검증되는 수용 기준은 각 API 문서 하단의 체크리스트를 참고하세요. + +--- + +## 3. 아키텍처 개요 (Hexagonal) +헥사고날 아키텍처를 적용하여 다음과 같은 레이어 규칙을 따릅니다. + +- domain: 순수한 도메인 모델과 비즈니스 규칙. 프레임워크 의존 금지. +- app: 유스케이스 서비스, 입력/출력 포트, 트랜잭션 경계, 검증, AOP. +- adapter: 웹 API, 보안, 영속성 등 외부 인터페이스. + +근거와 세부 규칙 +- 정책 문서: [docs/specs/policy](docs/specs/policy) +- 레이어 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 패키지 구조 예 + - 도메인: `src/main/java/org/mandarin/booking/domain/*` + - 앱/포트/영속 어댑터: `src/main/java/org/mandarin/booking/app/*` (`app/persist` 포함) + - 웹/보안 어댑터: `src/main/java/org/mandarin/booking/adapter/{webapi,security}/*` + +텍스트 다이어그램: [Controllers/Security/External] → adapter → app(ports, services) → domain + +--- + +## 4. 도메인 모델 요약 +- Movie (Aggregate Root): 제목, 감독, 장르, 상영시간, 개봉일, 등급, 줄거리, 포스터URL, 출연진 등. 팩토리/커맨드 기반 생성. +- Member (Aggregate Root): 닉네임, userId, email, passwordHash, 권한 목록. 비밀번호 해시 일치 검증. + +자세한 속성과 규칙: [docs/specs/domain.md](docs/specs/domain.md) + +--- + +## 5. 기술 스택과 선택 근거 +- 추후 작성 예정 + +선택 이유(요지) +- Hexagonal: 테스트 용이성과 변경 격리를 위해 계층 경계를 명확히. 또한, 추후 모듈화 or MSA 전환시 이점을 위해 애플리케이션 아키텍처를 영역에 따라 구분. +- Spring Security + JWT: 무상태(stateless) API 인증과 확장성. +- JPA + RDB(H2/MySQL): 표준 ORM과 빠른 테스트 사이클. + +--- + +## 6. 개발 방식과 테스트 전략 +- 테스트 주도 개발(TDD) 지향: 테스트 우선, 기능 추가 시 관련 스펙 테스트 동반. +- 테스트 정책 문서: [docs/specs/policy/test.md](docs/specs/policy/test.md) +- 통합 테스트: Spring Context 기동, 보안 필터/컨트롤러/JPA 연동을 포함한 경로 검증. + - 예시: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` +- 아키텍처 테스트: 레이어 규칙 준수 확인. + - 예시: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` + +Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profiles, JUnit Platform, ByteBuddy javaagent). + +--- + +## 7. 보안 개요 +- 필터 기반 JWT 인증: `JwtFilter`가 Authorization `Bearer `을 파싱해 SecurityContext 설정. +- 경로별 권한: `SecurityConfig`의 `@Order(1) apiChain` + - 공개: `POST /api/member`, `/api/auth/login`, `/api/auth/reissue` + - 권한 필요: `POST /api/movie`는 `ROLE_DISTRIBUTOR` +- 예외 처리: `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler` + +근거: `src/main/java/org/mandarin/booking/adapter/security/*` + +--- + +## 8. 데이터/환경 구성 +- 프로필: `local`(기본), `test`, `prod(비어있음)` + - 근거: `src/main/resources/application.yml` 및 `application-*.yml` +- local: MySQL + JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 + - 근거: `application-local.yml`, Docker Compose: [compose.yaml](compose.yaml) +- test: H2 메모리 + MySQL 호환 모드 + JPA `ddl-auto: create` + - 근거: `application-test.yml` + +민감정보는 운영 환경에서 환경변수로 주입하는 것을 권장합니다(로컬에 예시 값 존재). + +--- + +## 9. API 문서 +- 로그인: [docs/specs/api/login.md](docs/specs/api/login.md) +- 회원 가입: [docs/specs/api/member_register.md](docs/specs/api/member_register.md) +- 토큰 재발급: [docs/specs/api/reissue.md](docs/specs/api/reissue.md) +- 영화 등록: [docs/specs/api/movie_register.md](docs/specs/api/movie_register.md) + +각 문서 하단의 테스트 체크리스트가 수용 기준입니다. + +--- + +## 10. 프로젝트 상태 및 향후 계획 +- CI/CD, 코드 포매터, 마이그레이션 도구(Flyway/Liquibase)는 현재 문서/설정 부재로 "확인 불가" 상태입니다. +- TODO/메모: [docs/devlog/*](docs/devlog), [docs/todo.md](docs/todo.md) +- 권장 향후 작업 + - prod 프로필 구성과 비밀 주입 전략 수립 + - CI 파이프라인(.github/workflows) 도입 + - DB 마이그레이션 도구 채택 및 규약 수립 + - 인증/인가 정책 문서 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) + +--- + +## 11. 버전/도구 근거 링크 +- Spring Boot/Java/Gradle 버전: [build.gradle](build.gradle), [gradle-wrapper.properties](gradle/wrapper/gradle-wrapper.properties) +- 애플리케이션 엔트리포인트: `src/main/java/org/mandarin/booking/BookingApplication.java` +- 보안 설정/필터: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java`, `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- 아키텍처 규칙: [docs/specs/policy/application.md](docs/specs/policy/application.md) +- 테스트 정책: [docs/specs/policy/test.md](docs/specs/policy/test.md) diff --git a/build.gradle b/build.gradle index 83af410..3b64665 100644 --- a/build.gradle +++ b/build.gradle @@ -23,48 +23,43 @@ configurations { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - implementation("org.projectlombok:lombok:1.18.36") - annotationProcessor('org.projectlombok:lombok:1.18.36') - - // Spring web - implementation 'org.springframework.boot:spring-boot-starter-web' - - // Spring data JPA - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - - //h2 database - testRuntimeOnly 'com.h2database:h2' - - developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - - implementation 'com.mysql:mysql-connector-j:8.3.0' - - // Spring validation - implementation 'org.springframework.boot:spring-boot-starter-validation' - - // Spring security - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' - - // Mockito inline 사용 시 필요한 Byte Buddy Agent - byteBuddyAgent "net.bytebuddy:byte-buddy-agent:1.17.6" - - // spotbugs - spotbugs 'com.github.spotbugs:spotbugs:4.9.3' - - // p6spy - implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' - - // jjwt - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' - testImplementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + // ---- Spring Boot Core ---- + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-aop' + + // ---- Data & Database ---- + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.mysql:mysql-connector-j:8.3.0' + testRuntimeOnly 'com.h2database:h2' + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + + // ---- Security & Auth ---- + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + testImplementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + + // ---- Lombok ---- + implementation 'org.projectlombok:lombok:1.18.36' + annotationProcessor 'org.projectlombok:lombok:1.18.36' + + // ---- Dev Only ---- + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + + // ---- Code Quality / Tooling ---- + spotbugs 'com.github.spotbugs:spotbugs:4.9.3' + + // ---- Testing ---- + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' + testImplementation 'org.mockito:mockito-inline:5.2.0' } tasks.named('test') { diff --git a/docs/devlog/250821.md b/docs/devlog/250821.md new file mode 100644 index 0000000..8a1c434 --- /dev/null +++ b/docs/devlog/250821.md @@ -0,0 +1,11 @@ +## 예찬 +Spring Security의 filter **등록** 방식에 대한 이해가 부족하다고 느껴 실제로 등록하는 과정을 디버깅을 통해 확인해봤다. Spring Bea으로 등록된 filter는 기본적으로 servlet context에 의해 filter chain에 등록되지만 상세한 filter 우선순위를 결정하는데에 불편함이 있다. 그래서 Spring Security는 해당 filter의 우선순위를 보다 명시적으로 지정하기 위해 `SecurityFilterChain`을 사용해 filter의 우선순위를 명시하고, `DelegatingFilterChainProxy`가 이를 적용해준다. 그 과정에서 bean등록이 된 filter를 `SecurityFilterChain`에 명시하게 된다면 이 과정이 두번 동작하기 때문에 등록 과정이 두번 실행된다. 헷갈리지 말아야 할 개념은 실제 bean filter를 `DelegatingFilterChainProxy`가 두개 등록할 수는 없다.(애초에 Spring Singleton Bean이라서 인스턴스가 하나밖에 없기도 하다.) 다만,`DelegatingFilterChainProxy`가 `ApplicationContext`에서 빈을 가져올때 filter의 `beanName`으로 인스턴스를 레퍼런싱 하다보니 중복 등록을 제재할 방법은 없는것이다. 하지만 filter를 직접 적용하는 과정에서 동일 bean임이 인식되기 때문에 실질적으로 동작하는 인스턴스는 한번뿐인 것이다. 정리하자면 등록할 filter을 bean으로 등록하게 되면 자동으로 filter chain에 추가되기 때문에 가능하면 이 경우에는 `SecurityFilterChain`에 등록을 하지 말던가 아니면 bean으로 등록하지 않은 상태에서 `SecurityFilterChain`에 등록하도록 객체를 생성해야 중복 등록이 발생하지 않는다. + +## 휘동 +@Component로 filter를 스프링 빈으로 만들고, filter chain에 등록하면 filter가 중복 등록되는 것을 확인함. +filter를 빈으로 만들면 자동으로 servletfilterchain에 등록되는데, securityfilterchain에 빈을 주입받아 수동으로 등록하면서 중복됨. +실제 filter 동작은 한번만 일어나다. +GenericFilterBean을 상속받은 filter는 요청/응답 시 한번씩 총 2번 동작하고, +OncePerRequestFilter를 상속받은 filter는 요청에만 동작한다. +filter의 동작이 끝날 때 filterchain.doFilter()가 없으면 다음 필터(응답 시 동작해야할 필터 포함)는 작동하지 않는다. +filterchain.doFilter()의 유무로 필터의 중단을 결정하지 말고, GenericFilterBean와 OncePerRequestFilter를 적절히 사용해 필터 적용 시기를 조절함이 바람직하다. \ No newline at end of file diff --git a/docs/devlog/250829.md b/docs/devlog/250829.md new file mode 100644 index 0000000..db3efa7 --- /dev/null +++ b/docs/devlog/250829.md @@ -0,0 +1,6 @@ +## 예찬 +전반적인 인증 흐름에 대한 이해가 이번 기능 구현 과정에서 미흡했던것을 인지함. 이후 Spring Security의 인증 흐름을 학습하고, 그 과정에서 기존에 구현하려 했던 방식에 문제점을 발견했고, 이를 해결하기 위해 직접 제어 가능한 수준의 인증정보 제공자를 직접 구현하는 방식으로 기능 구현, 성공적인 테스트 케이스 통과가 가능했다. +이후에 해야할건 영화 조회 아닐까 싶다. 영화 조회 기능도 일단 빠르게 Spring Data JPA의 도움을 받아서 구현하고 이후 최적화 과정을 거치는 것이 좋지 않을까 하는 생각. +추가로, 현재까지 개발을 하는 과정에서 정책을 일부 작성해왔었는데, 이부분이 좀 누락된거 같다. 작성해두는게 앞으로 문제 발생 가능성을 줄이는데에 도움이 되지 않을까... + +## 휘동 diff --git a/docs/specs/api/login.md b/docs/specs/api/login.md index fb8ce71..ad8291b 100644 --- a/docs/specs/api/login.md +++ b/docs/specs/api/login.md @@ -26,8 +26,8 @@ -H 'Content-Type: application/json' \ -d ' { - "userId": "string", - "password": "string" + "userId": "test1234", + "password": "myPassword123" }' ``` @@ -55,4 +55,4 @@ - [x] 성공적인 로그인 후 응답에 accessToken과 refreshToken가 포함되어야 한다 - [x] 전달된 토큰은 유효한 JWT 형식이어야 한다 - [x] 전달된 토큰은 만료되지 않아야한다 -- [x] 전달된 토큰에는 사용자의 올바른 userId가 포함되어야 한다 \ No newline at end of file +- [x] 전달된 토큰에는 사용자의 올바른 userId가 포함되어야 한다 diff --git a/docs/specs/api/member_register.md b/docs/specs/api/member_register.md index 2f48dfc..616a242 100644 --- a/docs/specs/api/member_register.md +++ b/docs/specs/api/member_register.md @@ -23,7 +23,7 @@ curl 명령 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/members' \ + curl -i -X POST 'http://localhost:8080/api/member' \ -H 'Content-Type: application/json' \ -d '{ "nickName": "test", diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/movie_register.md new file mode 100644 index 0000000..1778998 --- /dev/null +++ b/docs/specs/api/movie_register.md @@ -0,0 +1,64 @@ +### 요청 + +- 메서드: `POST` +- 경로: `/api/movies` +- 헤더 + + ``` + Content-Type: application/json + Authorization: Bearer + ``` + +- 본문 + + ```json + + ``` + + +- curl 명령 예시 + + ```bash + curl -i -X POST 'http://localhost:8080/api/movie' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9ESVNUUklCVVRPUiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU2NDM4MjIzLCJleHAiOjE3NTY0Mzg4MjN9.DN0wZb8BdKY-7Grd0KAALXf88KX3iF_tg6UmcfotkFOlbRoRnSuY1nNVUFfZk2TxP0hvju3A8AglK3mt_hnutQ' \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "인셉션", + "director": "크리스토퍼 놀란", + "runtimeMinutes": 148, + "genre": "SF", + "releaseDate": "2010-07-21", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "casts": [ + "레오나르도 디카프리오", + "조셉 고든레빗", + "엘렌 페이지" + ] + }' + ``` + +### 응답 + +- 상태코드: `200 OK` +- 본문 + + ```json + { + "status": "SUCCESS", + "data": { + "movieId": 1 + }, + "timestamp": "2024-06-10T12:34:56.789Z" + } + ``` + +### 테스트 + +- [x] 올바른 요청을 보내면 status가 SUCCESS이다 +- [ ] 올바른 요청을 보내면 응답 본문에 movieId가 존재한다 +- [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 +- [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 +- [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 +- [x] releaseDate는 yyyy-MM-dd 형태를 준수하지 않으면 BAD_REQUEST이다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index a608486..57407b1 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -8,57 +8,78 @@ ## 도메인 모델 ### 영화(Movie) +_Aggregate Root_ - 상영될 콘텐츠 자체. #### 속성 -- 제목(Title): 영화의 이름. -- 감독(Director): 영화를 감독한 사람. -- 상영시간(Runtime): 영화의 총 길이(분 단위). -- 장르(Genre): 영화의 장르(예: 액션, 드라마, 코미디 등). -- 개봉일(ReleaseDate): 영화가 처음 개봉한 날짜. -- 등급(Rating): 영화의 관람 등급(예: 전체 관람가, 12세 관람가 등). -- 줄거리(Synopsis): 영화의 간단한 줄거리 설명. -- 포스터(Poster): 영화의 포스터 이미지 URL. -- 주연 배우(Cast): 영화에 출연한 주요 배우 목록. +- 제목(title) +- 감독(director) +- 상영시간(runtimeMinutes, 분) +- 장르(genre: ACTION/DRAMA/COMEDY/THRILLER/ROMANCE/SF/FANTASY/HORROR/ANIMATION/DOCUMENTARY/ETC) +- 관람등급(rating: ALL/AGE12/AGE15/AGE18) +- 개봉일(releaseDate, yyyy-MM-dd) +- 줄거리(synopsis) +- 포스터 URL(posterUrl) +- 출연 배우 목록(casts: Set) #### 행위 -- `register()`: 새로운 영화를 등록합니다. +- `create(command: MovieCreateCommand)`: 커맨드로부터 영화를 생성합니다. + +#### 관련 타입 +- `MovieCreateCommand`: 영화 생성 커맨드 + - title, genre, runtimeMinutes, director, synopsis, posterUrl, releaseDate, rating, casts(Set) +- `MovieRegisterRequest` / `MovieRegisterResponse`: 웹 API 요청/응답 DTO + +--- + +### 사용자(Member) +_Aggregate Root_ +- 서비스를 사용하는 사람(회원). + +#### 속성 +- 닉네임(nickName) +- 아이디(userId) — unique +- 비밀번호 해시(passwordHash) +- 이메일(email) +- 권한(authorities: List) — 기본값 USER + +#### 행위 +- `create(command: MemberCreateCommand, encoder: SecurePasswordEncoder)`: 암호화된 비밀번호로 회원을 생성합니다. +- `matchesPassword(rawPassword, encoder)`: 주어진 평문 비밀번호가 저장된 해시와 일치하는지 확인합니다. + +#### 관련 타입 +- `MemberCreateCommand` (inner record of Member): nickName, userId, password(평문), email +- `MemberRegisterRequest` / `MemberRegisterResponse`: 웹 API 요청/응답 DTO +- `MemberAuthority`: USER/DISTRIBUTOR/ADMIN 권한 정의, 컨버터를 사용해 문자열 영속화, 추가적인 테이블 생성 방지 + +--- ### 영화관(Cinema) +_Aggregate Root_ - 영화 상영 시설. +--- + ### 상영관(ScreeningRoom) +_Entity_ - 영화관 내에서 실제로 영화가 상영되는 개별 공간. +--- + ### 상영정보(ScreeningSchedule) +_Entity_ - 특정 영화가 특정 상영관에서 특정 날짜와 시간에 상영되는 스케줄. +--- ### 좌석(Seat) +_Entity_ - 상영관 내의 개별 의자. +--- + ### 예매(Reservation) +_Aggregate Root_ - 사용자가 특정 상영정보의 특정 좌석의 구매를 확정한 기록. -### 사용자(Member) -- 서비스를 사용하는 사람. -#### 속성 -- 사용자 ID(UserId): 사용자의 고유 식별자. -- 이름(NickName): 사용자의 이름. -- 아이디(UserId): 사용자의 고유 아이디. - unique -- 비밀번호(PasswordHash): 사용자의 암호화된 비밀번호. -- Email(Email): 사용자의 이메일 주소. - -#### 행위 -- `create()`: 새로운 영화를 등록합니다. -- `matchesPassword()`: 영화의 비밀번호를 확인합니다. - -### `MemberCreateCommand` -- 새로운 사용자를 생성하기 위한 명령. - -#### 속성 -- 사용자 ID(UserId): 사용자의 고유 식별자. -- 이름(NickName): 사용자의 이름. -- 아이디(UserId): 사용자의 고유 아이디. -- 비밀번호(PasswordHash): 사용자의 암호화된 비밀번호. -- Email(Email): 사용자의 이메일 주소. +--- diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md new file mode 100644 index 0000000..7a3cac6 --- /dev/null +++ b/docs/specs/policy/application.md @@ -0,0 +1,139 @@ +# 애플리케이션 아키텍처 규칙(헥사고날 아키텍처) + +본 문서는 booking 프로젝트가 채택한 헥사고날 아키텍처(Hexagonal Architecture, Ports & Adapters)의 규칙을 명확히 하기 위한 가이드입니다. 이 문서는 아키텍처 테스트와 코드 리뷰의 근거가 되며, 새로운 기능 추가 시 반드시 준수해야 합니다. + +## 1. 계층과 책임 + +프로젝트는 크게 세 계층으로 구성됩니다. + +- domain: 도메인 모델과 비즈니스 규칙의 순수 영역 + - 위치: `src/main/java/org/mandarin/booking/domain` + - 포함: 엔티티(를 표현하기 위한 매핑정보), 값 객체, 도메인 서비스(필요시), 도메인 예외, 도메인 전용 인터페이스(예: `SecurePasswordEncoder`), 유스케이스에 전달되는 순수 모델(`*Request`, `*Response`, `*Command` 등) + - 금지: 프레임워크/외부 라이브러리 의존(JPA/Spring/Web 등), I/O 접근, 인프라 세부 사항 + +- app: 애플리케이션 서비스(유스케이스)와 포트 인터페이스 + - 위치: `src/main/java/org/mandarin/booking/app` + - 포함: 유스케이스 서비스(`*Service`), 입력/출력 포트(`app/port`), 트랜잭션 경계, 조합/오케스트레이션 로직, 검증기(애플리케이션 수준), 크로스커팅(AOP, 로깅 등) + - 의존: domain에는 의존 가능, adapter에는 의존 금지 + +- adapter: 외부 세계와의 연결(웹, 보안, 영속성 등) + - 위치: `src/main/java/org/mandarin/booking/adapter` + - 하위 영역: + - `webapi`: REST 컨트롤러, DTO 매핑, 예외/응답 공통 처리 + - `security`: 인증/인가 컴포넌트(JwtFilter, AuthenticationProvider 등) + - `persist`: 영속성 구현은 현재 `app/persist` 패키지에 배치되어 있으며, 어댑터 구현으로 취급합니다. JPA 리포지토리와 실제 데이터 접근 로직이 위치합니다. + - 의존: app의 포트에만 의존해야 하며 domain, app 구현 내부로 직접 의존하지 않습니다(서비스 구현 클래스 참조 금지). + +텍스트 다이어그램: + +[Controllers/Security/JPA] → adapter → app(ports, services) → domain\(pure model) + +## 2. 의존성 규칙 + +- domain -> another domain +- app -> domain (OK), adapter (금지) +- adapter -> app 포트(OK), app 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 app 경유) +- DTO/엔티티 경계: + - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. + - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. + +## 3. 포트와 어댑터 + +- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `app/port` 컨트롤러는 입력 포트를 통해서만 유스케이스 호출. +- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `app/persist` 또는 `app/port` 하위에 정의 가능. +- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: adapter 하위. 현재 JPA 기반 구현은 `app/persist/*Repository`를 통해 동작하며, 해당 패키지는 어댑터 계층으로 간주합니다. + +권장 네이밍: +- 입력 포트: UseCase 동사형 + er (예: Registerer, UseCase) +- 출력 포트: 리소스 + 동작 + Repository/Gateway (예: MovieCommandRepository) +- 그 외에는 해당 인터페이스가 담당한 기능의 추상적 개념을 나타내는 네이밍 + +## 4. 트랜잭션/검증/예외/로깅 규칙 + +- 트랜잭션 경계: app 계층의 유스케이스 서비스 메서드 수준에서 관리(@Transactional). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. +- 검증: + - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. + - 비즈니스/정책 검증: app 또는 domain에서 수행. `Validator` 등의 컴포넌트는 app에 위치. +- 예외: + - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. + - 어댑터/기술 오류는 해당 계층에서 포착하고 app/domain 의미의 예외로 변환 또는 적절히 매핑. + - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. +- 로깅: 크로스커팅은 app 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. + +## 5. 패키지 구조 규칙 + +- domain: `org.mandarin.booking.domain.{boundedContext}` + - 예: `domain.member`, `domain.movie` +- app: `org.mandarin.booking.app` + - 하위: `port`, `persist`(출력 포트/구현), 서비스 클래스 +- adapter: `org.mandarin.booking.adapter.{webapi|security|...}` +- 순환 의존 금지: 위 규칙 위반 시 컴파일/테스트 단계에서 아키텍처 테스트 실패로 간주. + +## 6. 컨트롤러와 DTO 변환 규칙 + +- 컨트롤러는 입력 포트만 의존한다. +- 요청 DTO -> domain/app 요청 모델로 변환 후 유스케이스 호출. +- 유스케이스 반환값 -> web DTO로 매핑하여 응답한다. +- 컨트롤러에서 비즈니스 로직/트랜잭션 처리 금지. + +예시(영화 등록): +- `adapter/webapi/MovieController` -> `app/port/MovieRegisterer` 호출 +- `domain.movie.MovieRegisterRequest`/`MovieCreateCommand` 사용하여 유스케이스 실행 +- 결과를 `domain.movie.MovieRegisterResponse` 받아 web 응답으로 래핑(`ApiResponse`) + +## 7. 영속성 규칙(JPA) + +- JPA 엔티티는 domain에, Repository는 app(persist)에만 존재. +- app 계층은 JPA 구체 타입에 의존하지 않고, 출력 포트 인터페이스를 통해서만 데이터 접근. +- 매핑 책임은 어댑터에 위치: JPA 엔티티 <-> 도메인 엔티티/모델 변환. + +## 8. 보안 규칙 + +- 인증/인가 컴포넌트는 adapter/security에 위치: `JwtFilter`, `CustomAuthenticationProvider`, `SecurityConfig` 등. +- 보안 컨텍스트와 토큰 파싱은 어댑터에서 처리하고, app 유스케이스에는 인증된 식별자/역할만 전달. + +## 9. 테스트 규칙 + +- `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` 는 아키텍처 규칙을 자동 검증합니다. +- 규칙 위반 예: + - adapter가 app 서비스 구현 클래스에 직접 의존 + - app이 adapter 패키지에 의존 + - domain이 프레임워크에 의존 +- 새로운 모듈/클래스 추가 시 해당 테스트가 통과하는지 반드시 확인합니다. + +## 10. 확장 가이드(새 유스케이스/어댑터 추가) + +새 유스케이스(예: 영화 수정) 추가 절차: +1) domain에 필요한 모델/명세 정의(예: `MovieUpdateCommand`). +2) app/port에 입력 포트 정의(예: `MovieUpdater`). +3) app에 서비스 구현(`MovieService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. +4) 필요 시 출력 포트 정의 및 어댑터 구현(persist/JPA 등). +5) adapter/webapi에 컨트롤러 엔드포인트 추가 및 DTO 매핑. +6) 아키텍처/통합 테스트 통과 확인. + +새 어댑터(예: 외부 결제 API) 추가 절차: +1) app에 출력 포트 인터페이스 추가(예: `PaymentGateway`). +2) adapter 하위에 구현(예: `adapter/payment/PaymentGatewayHttpClient`). +3) 구성(Security/Config)과 예외 매핑 추가. + +## 11. 공통 규칙 요약(Do/Don’t) + +Do +- 유스케이스 입출력은 app 포트를 통해서만 노출/호출한다. +- 도메인 모델은 순수하게 유지한다(프레임워크 의존 금지). +- 어댑터는 포트 인터페이스를 구현한다. +- 트랜잭션과 로깅은 app에서 관리한다. + +Don’t +- 컨트롤러에서 비즈니스 로직 수행 금지. +- app에서 adapter 패키지/구현에 의존 금지. +- domain에서 JPA/Spring 등에 의존 금지. + +## 12. 용어 + +- 도메인 모델: 비즈니스 개념을 표현하는 순수 객체(`Member`, `Movie` 등) +- 유스케이스: 시스템이 제공하는 기능 단위(등록, 로그인 등) +- 포트: 유스케이스(입력) 또는 외부 의존(출력)을 추상화한 인터페이스 +- 어댑터: 포트를 구현하여 외부 세계와 연결하는 기술 계층 + +본 문서는 변경 시 PR에 포함하고, 아키텍처 테스트가 통과하는지 확인해야 합니다. diff --git a/docs/specs/policy/authentication.md b/docs/specs/policy/authentication.md new file mode 100644 index 0000000..206ac49 --- /dev/null +++ b/docs/specs/policy/authentication.md @@ -0,0 +1,99 @@ +# 인증 정책 (booking) + +본 문서는 booking 프로젝트의 인증(Authentication) 동작과 규칙을 정리합니다. 모든 내용은 저장소의 실제 코드/설정에 근거합니다. 확인 불가한 항목은 "확인 불가"로 표기합니다. + +근거 파일/경로: +- 보안 설정: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java` +- JWT 필터: `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- AuthenticationProvider: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java` +- 토큰 유틸: `src/main/java/org/mandarin/booking/app/TokenUtils.java` +- 예외 처리기: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java`, `CustomAccessDeniedHandler.java` +- 프로필/환경: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` + +--- + +## 1. 인증 흐름 개요 +- 모든 `/api/**` 요청은 `@Order(1)` 체인의 보호를 받습니다. (근거: SecurityConfig.apiChain) +- 인증 헤더: `Authorization: Bearer ` (근거: JwtFilter) +- JwtFilter가 토큰을 파싱하여 사용자 식별자와 권한 정보를 추출하고, `AuthenticationManager`(= `CustomAuthenticationProvider`)를 통해 인증 토큰을 완성합니다. +- 세션 상태: Stateless (세션 생성 비활성화), CSRF 비활성화. (근거: SecurityConfig.apiChain 설정) + +텍스트 시퀀스: +1) 클라이언트 → API: Authorization 헤더 전달 +2) JwtFilter: `Bearer` 타입 여부 확인 → 토큰 파싱 → `userId`, `roles` 추출 +3) JwtFilter: `CustomMemberAuthenticationToken(userId, authorities)` 생성 → `AuthenticationManager.authenticate(...)` 위임 +4) CustomAuthenticationProvider: `MemberQueryRepository`로 사용자 조회 → `MemberDetails` 설정 → 인증 토큰 확정 +5) SecurityContext에 인증 정보 저장 → 이후 필터/컨트롤러에서 사용 + +--- + +## 2. JWT 토큰 규칙 +- 헤더 키: `Authorization` +- 포맷: `Bearer ` +- 사용 Claims (근거: JwtFilter): + - `userId`: 사용자 식별자 + - `roles`: 문자열 리스트. 예: `["ROLE_USER", "ROLE_DISTRIBUTOR"]` +- 권한 매핑: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 뒤 `MemberAuthority` enum으로 변환하여 `CustomMemberAuthenticationToken`에 부여합니다. (근거: JwtFilter.getAuthorities) +- 토큰 서명/TTL 설정: 프로필 별 설정 사용 + - `jwt.token.secret`: 서명 시크릿(Base64 인코딩 값) + - `jwt.token.access`: Access Token 만료(ms) + - `jwt.token.refresh`: Refresh Token 만료(ms) + - 근거: `application-local.yml`, `application-test.yml` + +주의: +- 헤더가 없거나 `Bearer` 접두사만 온 경우 익명 처리되며, 요청 속성 `exception`에 `AuthException`이 설정됩니다. (근거: JwtFilter.isTokenBlank, doFilterInternal) +- 서명 오류/만료/클레임 파싱 실패 시 `AuthException`을 설정하고 SecurityContext를 비웁니다. (근거: JwtFilter 예외 처리) + +--- + +## 3. 인증 컴포넌트 +- JwtFilter + - 위치/순서: `UsernamePasswordAuthenticationFilter` 앞 (근거: SecurityConfig.addFilterBefore) + - 역할: Authorization 헤더 파싱, 사용자 정보 추출, AuthenticationManager 위임, SecurityContext 설정 +- CustomAuthenticationProvider + - 지원 타입: `CustomMemberAuthenticationToken` (근거: supports) + - 동작: 토큰 내 userId로 회원 조회(`MemberQueryRepository.findByUserId`) → 없으면 `AuthException` → 있으면 `MemberDetails` 주입, 인증 확정 +- TokenUtils + - 역할: JWT에서 개별 클레임/리스트 클레임 추출 (근거: JwtFilter에서 사용) + +--- + +## 4. 인증 예외 처리 +- 인증 실패(미인증) 시: `CustomAuthenticationEntryPoint`가 응답 생성 (상세 형식은 클래스 구현 참고) +- 인가 실패(권한 부족) 시: `CustomAccessDeniedHandler`가 응답 생성 +- JwtFilter는 내부적으로 `request.setAttribute("exception", new AuthException(...))`로 실패 사유를 넘기며, 이후 예외 처리기가 이를 사용해 응답을 형성할 수 있습니다. + +--- + +## 5. 공개 엔드포인트와 인증 필요 엔드포인트 +- 공개(permitAll): (근거: SecurityConfig.apiChain) + - `POST /api/member` + - `POST /api/auth/login` + - `POST /api/auth/reissue` +- 인증 필요: (근거: SecurityConfig.apiChain) + - 위 공개 엔드포인트를 제외한 모든 `/api/**` 요청은 인증 필요 +- 공개 체인(@Order(2)): (근거: SecurityConfig.publicChain) + - `/error`, `/assets/**`, `/favicon.ico` 및 그 외 `/**`는 permitAll (운영상 공개 페이지용) + +--- + +## 6. 테스트/프로필 연계 +- 테스트 실행 시 프로필 `test` 활성화: Gradle test 태스크에서 설정 (근거: build.gradle) +- 테스트 프로필에서 JWT/DB 설정은 `application-test.yml`을 따른다. +- 보안 관련 통합/단위 테스트는 다음을 참조: `src/test/java/org/mandarin/booking/adapter/security/*` + +--- + +## 7. 확장 가이드(인증) +- 새로운 인증 스킴 도입 시 지켜야 할 규칙: + - JwtFilter 앞/뒤 필터 추가 시 순서 충돌 주의. 인증 헤더 파싱 필터는 반드시 UsernamePasswordAuthenticationFilter 이전. + - CustomAuthenticationProvider는 `supports`/`authenticate` 계약을 준수하여 `Authentication` 토큰 타입을 명확히 구분. + - 토큰 클레임 확장 시: `TokenUtils`와 `JwtFilter.getAuthorities()` 동기화. 권한 문자열은 `ROLE_` 접두사를 유지. +- 운영 비밀: 시크릿/TTL 값은 환경변수로 덮어쓰기를 권장. (application-prod.yml은 현재 비어 있음 → 확인 불가) + +--- + +## 8. 알 수 없는 항목(확인 불가) +- 토큰 발급/서명 구현 세부(Access/Refresh 생성 로직) 문서화 수준: 확인 불가 (해당 클래스 상세는 별도 코드 참조 필요) +- 키 회전/블랙리스트/토큰 철회 전략: 확인 불가 + diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md new file mode 100644 index 0000000..f2147b9 --- /dev/null +++ b/docs/specs/policy/authorization.md @@ -0,0 +1,73 @@ +# 인가 정책 (booking) + +본 문서는 booking 프로젝트의 인가(Authorization) 규칙을 경로/메서드/권한 기준으로 명확히 기술합니다. 모든 내용은 실제 보안 설정 코드에 근거합니다. + +근거 파일/경로: +- 보안 설정: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java` +- 권한 Enum: `src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java` +- JWT 필터: `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` +- 예외 처리기: `src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java`, `CustomAuthenticationEntryPoint.java` + +--- + +## 1. 기본 원칙 +- 인가는 Spring Security의 `SecurityFilterChain` 규칙으로 정의됩니다. +- 권한 문자열은 `ROLE_` 접두사를 가진 형태로 JWT `roles` 클레임에 담깁니다. (예: `ROLE_USER`, `ROLE_DISTRIBUTOR`, `ROLE_ADMIN`) (근거: JwtFilter.getAuthorities, MemberAuthority) + +--- + +## 2. 경로/메서드 별 인가 규칙 +아래 표는 `SecurityConfig.apiChain`의 `authorizeHttpRequests` 설정을 반영합니다. + +- 공개(permitAll): + - `POST /api/member` + - `POST /api/auth/login` + - `POST /api/auth/reissue` + +- 권한 필요(hasAuthority): + - `POST /api/movie` → `ROLE_DISTRIBUTOR` + +- 그 외 `/api/**`: + - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음). 컨트롤러/도메인 단에서 별도 검증이 필요한 경우 추가 로직으로 보완. + +- 퍼블릭 체인(@Order(2)): + - `/error`, `/assets/**`, `/favicon.ico`, 및 그 외 `/**`는 permitAll (정적/오류/기타 공개 경로) + +근거 코드 스니펫 요약: +- `http.securityMatcher("/api/**")` +- `.requestMatchers(HttpMethod.POST, "/api/member").permitAll()` +- `.requestMatchers("/api/auth/login").permitAll()` +- `.requestMatchers("/api/auth/reissue").permitAll()` +- `.requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR")` +- `.anyRequest().authenticated()` + +--- + +## 3. 권한 명명 규칙과 매핑 +- Enum: `MemberAuthority`는 다음 권한을 가집니다(코드 참조). + - USER, DISTRIBUTOR, ADMIN 등 +- JWT `roles` → Spring Security 권한 변환: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 후 `MemberAuthority.valueOf(...)`로 Enum을 만들어 `CustomMemberAuthenticationToken`에 저장합니다. (근거: JwtFilter.getAuthorities) +- hasAuthority 비교 시에는 문자열 `ROLE_*` 형태를 사용합니다. (근거: SecurityConfig 설정) + +--- + +## 4. 예외/오류 처리 +- 인증 실패(401 Unauthorized): `CustomAuthenticationEntryPoint`가 처리 +- 권한 부족(403 Forbidden): `CustomAccessDeniedHandler`가 처리 +- JwtFilter는 유효하지 않은 토큰, 누락된 토큰 등에 대해 `request.setAttribute("exception", AuthException)`을 설정하여 원인 정보를 예외 처리기로 전달합니다. + +--- + +## 5. 확장 가이드(인가 규칙 추가 방법) +- 새로운 엔드포인트 추가 시 규칙 예시: + - 공개 엔드포인트(회원가입/로그인 유사): `.requestMatchers(HttpMethod.POST, "/api/xxx").permitAll()` + - 역할 제한 엔드포인트: `.requestMatchers(HttpMethod.PUT, "/api/movies/{id}").hasAuthority("ROLE_ADMIN")` + - 복수 권한 허용: `.requestMatchers(HttpMethod.POST, "/api/screening").hasAnyAuthority("ROLE_DISTRIBUTOR", "ROLE_ADMIN")` +- 규칙 배치 위치: `SecurityConfig.apiChain`의 `authorizeHttpRequests` 빌더에 메서드/경로/권한을 추가합니다. +- 테스트: 추가/변경 시 반드시 보안 통합 테스트를 작성하여 401/403, 성공 경로를 검증하십시오. (예: `adapter/security/*Test.java`, `webapi/**` 스펙 테스트) + +--- + +## 6. 알 수 없는 항목(확인 불가) +- 경로 별 세부 권한 정책 문서(도메인별 Role Matrix): 현재 저장소에 상세 표 없음 → 확인 불가 +- 동적 권한(도메인 데이터 소유권 기반 세분화) 정책: 확인 불가 diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md new file mode 100644 index 0000000..d8cc766 --- /dev/null +++ b/docs/specs/policy/test.md @@ -0,0 +1,170 @@ +# 테스트 정책 (booking) + +본 문서는 booking 프로젝트의 테스트 작성/실행 표준을 정의합니다. 실제 저장소의 테스트 코드, Gradle 설정, 애플리케이션 프로필을 근거로 수립되었습니다. 이 문서는 코드 변경 시 항상 최신 상태로 유지되어야 하며, 테스트 실패는 정책 위반으로 간주할 수 있습니다. + +근거 파일/경로: +- build.gradle: test 태스크, 라이브러리, JVM args 설정 +- src/main/resources/application-test.yml: 테스트 프로필 환경 +- src/test/java/**/*: 실제 테스트 코드 일체 +- docs/specs/api/*.md: API별 수용 기준(체크리스트) + +--- + +## 1. 목표와 범위 +- 목표: 기능/아키텍처/보안/도메인 규칙을 신뢰성 있게 검증한다. +- 범위: 단위 테스트(Unit), 통합 테스트(Integration), 아키텍처 테스트(ArchUnit) 전반. +- 테스트 실행 환경은 Gradle test 태스크를 표준으로 한다. + +명령: +- 전체 테스트: `./gradlew test` +- 테스트 프로필: Gradle가 자동으로 `spring.profiles.active=test`를 설정함 (build.gradle 근거). + +--- + +## 2. 테스트 종류와 원칙 + +### 2.1 단위 테스트 (Unit Test) +- 목적: 작은 단위(도메인, 유틸, 애플리케이션 서비스의 순수 로직)의 동작을 빠르고 고립적으로 검증. +- 프레임워크 의존: 가급적 없음. Spring Context를 기동하지 않는다. +- 목킹(Mock): 외부 의존성은 Mockito 등으로 대체. 저장소/네트워크/시큐리티 등 I/O 경계를 모킹한다. +- 예시 근거: + - 도메인: `src/test/java/org/mandarin/booking/domain/MemberTest.java`, `AbstractEntityTest.java` + - 보안 컴포넌트 단위: `adapter/security/JwtFilterTest.java`, `CustomAuthenticationProviderTest.java`, `CustomAuthenticationEntryPointTest.java` + - 공통/web 단위: `adapter/webapi/GlobalExceptionHandlerTest.java` +- 라이브러리/설정 근거: + - Mockito inline 사용: `build.gradle` → `testImplementation 'org.mockito:mockito-inline:5.2.0'` + - JUnit5 사용: `build.gradle` → `useJUnitPlatform()` + - ByteBuddy javaagent 사전 부착: `build.gradle` → `jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}"` + +권장 규칙: +- 네이밍: 테스트 클래스는 대상 클래스명 + `Test` 또는 시나리오 중심 스펙 명(`*Specs`)을 사용 가능. +- 패키지: 테스트 대상과 유사한 패키지 경로 하위에 배치하여 접근성을 높인다. +- given-when-then 주석 또는 메서드명으로 시나리오를 명확히 표현한다. +- 외부 시스템/DB 액세스 금지. 필요한 경우 포트/리포지토리를 모킹. + +### 2.2 통합 테스트 (Integration Test) +- 목적: Spring Context를 실제로 기동하여, 보안 필터/컨트롤러/시리얼라이저/예외 처리 및 JPA/H2 동작을 포함해 엔드투엔드에 가까운 경로를 검증. +- 프로필/환경: `application-test.yml` 사용. H2 메모리 DB, JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 포함. +- 보안: 실제 `SecurityConfig`와 `JwtFilter` 동작을 최대한 반영. 필요 시 테스트 전용 컨트롤러/설정 (`TestOnlyController`, `TestConfig`) 사용. +- 유틸리티: `IntegrationTest`, `IntegrationTestUtils`, `IntegrationTestUtilsSpecs`, `JwtTestUtils` 등 공용 유틸을 통해 테스트 준비/토큰 생성/컨텍스트 초기화. +- 예시 근거: + - 웹 API 스펙 테스트: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` + - 통합 환경 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java` +권장 규칙: +- `@IntegrationTest` 커스텀 어노테이션 사용으로 공통 설정 +- 각 테스트는 `IntegrationTestUtils`를 사용해 작성 + - `IntegrationTestUtils` 사용 방법은 다음과 같음 + - ```java + @Test + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { + // Act & Assert + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); + } + ``` + - ```java + @Test + void failToAuth(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + ``` +- 데이터 초기화는 테스트 메서드 단위로 독립되게 유지. H2 메모리 DB가 매 테스트 클래스/메서드 기준으로 깨끗한 상태를 갖도록 설계한다. +- 인증이 필요한 엔드포인트는 `JwtTestUtils`로 유효 토큰을 발급하여 헤더 `Authorization: Bearer `를 부착. +- 예외/에러 응답은 `GlobalExceptionHandler` 정책에 맞춰 상태코드/본문을 검증. +- 아키텍처적으로 adapter → app → domain 경로를 실제 호출하여 레이어 간 계약을 검증. +- 검증은 최대한 `assertj`의 `assertThat`을 사용해 검증 통일. + +### 2.3 아키텍처 테스트 (ArchUnit) +- 목적: 헥사고날 계층 규칙 준수 보장. +- 근거 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 핵심 규칙: + - adapter 레이어는 어떤 레이어에도 접근 허용되지 않음(외부에서 접근 금지). + - application 레이어는 adapter에서만 접근 가능. + - domain 레이어는 adapter, application에서만 접근 가능. + +--- + +## 3. 테스트-환경 설정 +- Gradle test 태스크가 `spring.profiles.active=test`로 실행됨: `build.gradle` 65~70행 참고. +- H2 설정: `application-test.yml` + - URL: `jdbc:h2:mem:test;MODE=MySQL;` + - Hibernate Dialect: `H2Dialect` + 테스트 중 MySQL 호환 모드 + - JPA: `ddl-auto: create`, `format_sql/show_sql: true` +- JWT 설정: `application-test.yml`의 `jwt.token.secret/access/refresh` +- 정적 분석: 필요시 `./gradlew spotbugsMain spotbugsTest` 병행 실행 가능. + +--- + +## 4. 작성 규칙 + +### 4.1 단위 테스트 작성 체크리스트 +- [ ] 단일 책임/작은 단위만 검증한다. +- [ ] 외부 의존은 Mockito로 모킹한다. +- [ ] 스프링 컨텍스트를 기동하지 않는다. +- [ ] 성공/실패 경로를 모두 검증한다(예외 포함). +- [ ] 경계값, 널/빈값 케이스 포함. + +### 4.2 통합 테스트 작성 체크리스트 +- [ ] Spring 컨텍스트 기동 및 필요한 빈 주입 확인. +- [ ] 보안 필터/JWT 인증 흐름을 실제로 검증한다. +- [ ] 컨트롤러 → 앱 서비스 → 영속성(JPA/H2) 경로를 통해 상태 변화/응답을 확인한다. +- [ ] API 명세 문서(docs/specs/api/*.md)의 체크리스트를 테스트 케이스로 반영한다. +- [ ] 테스트 독립성을 보장하고, 데이터 격리를 유지한다. + +### 4.3 공통 규칙 +- 테스트명/메서드명은 자연어에 가깝게 시나리오를 드러낸다(한글/영문 허용). +- 반복 셋업은 유틸/추상 베이스 클래스로 추출(예: `IntegrationTestUtils`). +- 민감정보(비밀번호, 토큰 원문 등)는 로그에 남기지 않는다. + +--- + +## 5. API 스펙 연동 +- 각 API 문서의 "테스트" 체크박스를 충족하는 테스트를 작성/유지한다. + - 로그인: `docs/specs/api/login.md` + - 회원가입: `docs/specs/api/member_register.md` + - 토큰 재발급: `docs/specs/api/reissue.md` + - 영화 등록: `docs/specs/api/movie_register.md` +- 체크박스는 수용 기준(acceptance criteria)로 간주하며, 누락 시 테스트 보완 또는 문서 동기화가 필요하다. + +--- + +## 6. 실행 방법과 성능 +- 표준 실행: `./gradlew test` +- 통합 테스트의 컨텍스트 초기화 비용이 크므로, 로컬 개발 중에는 대상 패키지/클래스만 선별 실행을 권장. +- 빠른 피드백: 도메인/유틸 단위 테스트 우선 실행 → 이후 통합 테스트. + +--- + +## 7. CI 연동 +- GitHub Actions 등 CI 정의는 현재 저장소에서 확인 불가. +- 향후 CI 도입 시, 최소 요구: `./gradlew clean test` + ArchUnit + SpotBugs. + +--- + +## 8. 디렉터리/네이밍 가이드 +- 테스트 루트: `src/test/java` +- 관례: + - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` + - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 + - 예시) + - POST `/api/auth/login`: `src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` + - GET `/api/movies`: `src/test/java/org/mandarin/booking/webapi/movies/GET_specs.java` + - 아키텍처 테스트: `arch/*` + +--- + +## 9. 부록: 참고 클래스 +- ArchUnit: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 보안 테스트: `src/test/java/org/mandarin/booking/adapter/security/*.java` +- 웹 API 스펙: `src/test/java/org/mandarin/booking/webapi/**` +- 통합 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java` diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..75bcf92 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,18 @@ + +2025.08.25 +- [x] `올바른_요청을_보내면_status가_SUCCESS이다` 테스트의 거짓 양성 문제 해결 필요? -> 그것보단 테스트 케이스의 부실한 부분을 수정하는게 맞는듯 + - 올바르다의 기준이 명확할 필요가 있음 +- [x] `TestResult`에 인증을 위한 토큰 입력부를 추가할 필요가 있음 + +2025.08.26 +- [x] `TestResult`에 인증을 위한 토큰 입력부 추가 + +2025.08.27 +- [x] 테스트 케이스 충족 + +2025.08.28 +- [x] 없는 엔드포인트에 대한 처리 어찌할지 고민 +- [x] 리펙터링 + +2025.08.29 +- [ ] `/api/members`와 같이 `/api`로 시작하지만 존재하지 않는 엔드포인트에 대한 처리가 `AuthenticationEntryPoint`에서 처리되고 있음 diff --git a/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java b/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java new file mode 100644 index 0000000..c1a70af --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.adapter; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonCustomizerConfig { + @Bean + Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> { + builder.timeZone("Asia/Seoul"); + builder.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + }; + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..8ec2f83 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package org.mandarin.booking.adapter.security; + +import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + log.error("Access Denied: {}", accessDeniedException.getMessage()); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + var errorResponse = new ErrorResponse(FORBIDDEN, "Access Denied"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..83df390 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package org.mandarin.booking.adapter.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.adapter.webapi.ApiStatus; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + var exception = (Exception)(request.getAttribute("exception")); + var message = getMessage(authException, exception); + var errorResponse = new ErrorResponse(ApiStatus.UNAUTHORIZED, message); + objectMapper.writeValue(response.getWriter(), errorResponse); + } + + private String getMessage(AuthenticationException authException, Exception exception) { + if (exception != null) { + return exception.getMessage(); + } + return authException.getMessage(); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java new file mode 100644 index 0000000..0da27ce --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java @@ -0,0 +1,41 @@ +package org.mandarin.booking.adapter.security; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.Member; +import org.mandarin.booking.domain.member.MemberDetails; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationProvider implements AuthenticationProvider { + private final MemberQueryRepository queryRepository; + + @Override + public boolean supports(Class authentication) { + return CustomMemberAuthenticationToken.class.isAssignableFrom(authentication); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication instanceof CustomMemberAuthenticationToken token) { + var userId = token.getName(); + var member = queryRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException("해당 아이디의 사용자를 찾을 수 없습니다: " + userId)); + + specifyToken(token, member); + return token; + } + throw new AuthException("Unsupported authentication type: " + authentication.getClass()); + } + + private void specifyToken(CustomMemberAuthenticationToken token, Member member) { + MemberDetails details = MemberDetails.from(member); + token.setDetails(details);// set user details + token.setAuthenticated(true); + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java new file mode 100644 index 0000000..ac1f9d8 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java @@ -0,0 +1,31 @@ +package org.mandarin.booking.adapter.security; + +import java.util.Collection; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class CustomMemberAuthenticationToken extends AbstractAuthenticationToken { + private final String userId; + + public CustomMemberAuthenticationToken(String userId, Collection authorities) { + super(authorities); + this.userId = userId; + super.setAuthenticated(true); + } + + @Override + public String getName() { + return userId; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return this.getDetails(); + } + +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java new file mode 100644 index 0000000..cc67cd3 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java @@ -0,0 +1,81 @@ +package org.mandarin.booking.adapter.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.MemberAuthority; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private static final String PREFIX = "Bearer "; + private static final String AUTHORIZATION = "Authorization"; + private static final String EXCEPTION = "exception"; + + private final TokenUtils tokenUtils; + private final AuthenticationManager authenticationManager; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader(AUTHORIZATION); + if (isTokenBlank(header)) { + SecurityContextHolder.clearContext(); + request.setAttribute("exception", new AuthException("토큰이 비어있습니다.")); + filterChain.doFilter(request, response); + return; + } + + if (isBearer(header)) { + String token = header.substring(PREFIX.length()); + + try { + var userId = tokenUtils.getClaim(token, "userId"); + var authorities = getAuthorities(token); + var authToken = new CustomMemberAuthenticationToken(userId, authorities); + + var authenticate = authenticationManager.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(authenticate); + } catch (AuthException e) { + log.error("Authentication Error: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute(EXCEPTION, e); + } + } + + if (isAnonymous()) { + request.setAttribute(EXCEPTION, new AuthException("유효한 토큰이 없습니다.")); + } + + filterChain.doFilter(request, response); + } + + private List getAuthorities(String token) { + var roles = tokenUtils.getClaims(token, "roles"); + return roles.stream() + .map(r -> r.substring(5)) // "ROLE_" 접두사 제거 + .map(MemberAuthority::valueOf).toList(); + } + + private boolean isTokenBlank(String header) { + return header == null || header.equals("Bearer"); + } + + private boolean isBearer(String header) { + return header.startsWith(PREFIX); + } + + private boolean isAnonymous() { + return SecurityContextHolder.getContext().getAuthentication() == null; + } +} diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java new file mode 100644 index 0000000..2266591 --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -0,0 +1,71 @@ +package org.mandarin.booking.adapter.security; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.TokenUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + private final TokenUtils tokenUtils; + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + @Order(1) + public SecurityFilterChain apiChain(HttpSecurity http, + AuthenticationProvider authenticationProvider, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler) + throws Exception { + http + .securityMatcher("/api/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/member").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/api/auth/reissue").permitAll() + .requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR") + .anyRequest().authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + @Order(2) + SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/error", "/assets/**", "/favicon.ico").permitAll() + .anyRequest().permitAll() + ) + .securityContext(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } +} diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java similarity index 86% rename from src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java index 5653b4d..bcf8995 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ApiResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import java.time.LocalDateTime; import lombok.Getter; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java similarity index 69% rename from src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java index 18d2b9d..bba5da6 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ApiStatus.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; /** * Centralizes API status codes to achieve type-safety and remove string duplication. @@ -8,5 +8,7 @@ public enum ApiStatus { SUCCESS, BAD_REQUEST, UNAUTHORIZED, - INTERNAL_SERVER_ERROR + INTERNAL_SERVER_ERROR, + FORBIDDEN, + NOT_FOUND } diff --git a/src/main/java/org/mandarin/booking/infra/webapi/AuthController.java b/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java similarity index 78% rename from src/main/java/org/mandarin/booking/infra/webapi/AuthController.java rename to src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java index 584aaa0..a940a93 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/AuthController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java @@ -1,10 +1,10 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; import org.mandarin.booking.app.port.AuthUseCase; -import org.mandarin.booking.infra.webapi.dto.AuthRequest; -import org.mandarin.booking.infra.webapi.dto.ReissueRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.AuthRequest; +import org.mandarin.booking.domain.member.ReissueRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java b/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java similarity index 98% rename from src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java rename to src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java index f9edfb3..4e57897 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/CommonHttpMessageConverter.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java similarity index 81% rename from src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java index ba0c0c9..28f7cdb 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ErrorResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java @@ -1,8 +1,10 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.ToString; +@ToString public class ErrorResponse extends ApiResponse { public ErrorResponse(ApiStatus status, String message) { diff --git a/src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java similarity index 53% rename from src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java rename to src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java index ac52ecc..38205c9 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/GlobalExceptionHandler.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java @@ -1,29 +1,39 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import static java.util.Objects.requireNonNull; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import org.mandarin.booking.domain.DomainException; import org.mandarin.booking.domain.member.AuthException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { - return new ErrorResponse(ApiStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + return new ErrorResponse(INTERNAL_SERVER_ERROR, ex.getMessage()); } @ExceptionHandler(AuthException.class) public ErrorResponse handleAuthException(AuthException ex) { - return new ErrorResponse(ApiStatus.UNAUTHORIZED, ex.getMessage()); + return new ErrorResponse(UNAUTHORIZED, ex.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ErrorResponse handleValidationException(MethodArgumentNotValidException ex) { - return new ErrorResponse(ApiStatus.BAD_REQUEST, + return new ErrorResponse(BAD_REQUEST, requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage()); } + + @ExceptionHandler(NoHandlerFoundException.class) + public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { + return new ErrorResponse(NOT_FOUND, ex.getMessage()); + } } diff --git a/src/main/java/org/mandarin/booking/infra/webapi/MemberController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java similarity index 73% rename from src/main/java/org/mandarin/booking/infra/webapi/MemberController.java rename to src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java index 30d5f49..7483a15 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/MemberController.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java @@ -1,16 +1,16 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.app.port.MemberRegisterer; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/members") +@RequestMapping("/api/member") public record MemberController(MemberRegisterer memberRegisterer) { @PostMapping diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java new file mode 100644 index 0000000..c92eefe --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java @@ -0,0 +1,20 @@ +package org.mandarin.booking.adapter.webapi; + +import jakarta.validation.Valid; +import org.mandarin.booking.app.port.MovieRegisterer; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/movie") +public record MovieController(MovieRegisterer movieRegisterer) { + + @PostMapping + public MovieRegisterResponse register(@RequestBody @Valid MovieRegisterRequest request) { + return movieRegisterer.register(request); + } +} diff --git a/src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java similarity index 91% rename from src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java rename to src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java index 92fd94f..270fbb1 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/ResponseWrapper.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; @@ -22,10 +22,6 @@ public Object beforeBodyWrite(final Object body, final MethodParameter returnTyp final MediaType selectedContentType, final Class> selectedConverterType, final ServerHttpRequest request, final ServerHttpResponse response) { - if (body instanceof ApiResponse) { - return body; - } - return new SuccessResponse<>(ApiStatus.SUCCESS, body); } } diff --git a/src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java similarity index 76% rename from src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java rename to src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java index df4bd36..7d7e55f 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/SuccessResponse.java +++ b/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java @@ -1,9 +1,11 @@ -package org.mandarin.booking.infra.webapi; +package org.mandarin.booking.adapter.webapi; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; @Getter +@ToString @NoArgsConstructor public class SuccessResponse extends ApiResponse { diff --git a/src/main/java/org/mandarin/booking/app/AuthService.java b/src/main/java/org/mandarin/booking/app/AuthService.java index a7ce07e..ae122a7 100644 --- a/src/main/java/org/mandarin/booking/app/AuthService.java +++ b/src/main/java/org/mandarin/booking/app/AuthService.java @@ -1,9 +1,9 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.TokenHolder; import org.mandarin.booking.app.port.AuthUseCase; import org.mandarin.booking.domain.member.AuthException; import org.mandarin.booking.domain.member.Member; @@ -14,28 +14,27 @@ public class AuthService implements AuthUseCase { private final SecurePasswordEncoder securePasswordEncoder; private final MemberQueryRepository queryRepository; - private final TokenProvider tokenProvider; + private final TokenUtils tokenUtils; @Override public TokenHolder login(String userId, String password) { var member = getMember(userId); checkPasswordMatch(member, password); - return tokenProvider.generateToken(member.getUserId(), member.getNickName()); + return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()); } @Override public TokenHolder reissue(String refreshToken) { - tokenProvider.validateToken(refreshToken); - var userId = tokenProvider.getClaim(refreshToken, "userId"); + var userId = tokenUtils.getClaim(refreshToken, "userId"); if(!queryRepository.existsByUserId(userId)) - throw new AuthException("Member does not exist"); - return tokenProvider.generateToken(refreshToken); + throw new AuthException("회원이 존재하지 않습니다"); + return tokenUtils.generateToken(refreshToken); } private void checkPasswordMatch(Member member, String password) { if (!member.matchesPassword(password, securePasswordEncoder)) { - throw new AuthException("Invalid userId or password"); + throw new AuthException("잘못된 userID 또는 비밀번호"); } } diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenProvider.java b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java similarity index 56% rename from src/main/java/org/mandarin/booking/app/JwtTokenProvider.java rename to src/main/java/org/mandarin/booking/app/JwtTokenUtils.java index c6030f7..4874b77 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenProvider.java +++ b/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java @@ -6,19 +6,27 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.crypto.SecretKey; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.domain.member.MemberAuthority; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; @Component -public class JwtTokenProvider implements TokenProvider { +public class JwtTokenUtils implements TokenUtils { private static final String USER_ID = "userId"; private static final String NICK_NAME = "nickName"; + private static final String ROLES = "roles"; @Value("${jwt.token.access}") private long accessTokenExp; @@ -33,22 +41,44 @@ public void setKey(@Value("${jwt.token.secret}") String secretKey) { this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); } + @Override + public TokenHolder generateToken(String userId, String nickName, + Collection authorities) { + String accessToken = generateTokenInternal(userId, nickName, authorities, accessTokenExp); + String refreshToken = generateTokenInternal(userId, nickName, authorities, refreshTokenExp); + return new TokenHolder(accessToken, refreshToken); + } + @Override public TokenHolder generateToken(String refreshToken) { var claims = parseClaims(refreshToken); String userId = claims.getPayload().get(USER_ID).toString(); String nickName = claims.getPayload().get(NICK_NAME).toString(); - return generateToken(userId, nickName); + List authorities = Arrays.stream(claims.getPayload().get(ROLES, String.class).split(",")) + .map(s->s.substring(5)) + .map(MemberAuthority::valueOf) + .toList(); + return generateToken(userId, nickName, authorities); + } + + + @Override + public String getClaim(String token, String claimName) { + Jws claims = parseClaims(token); + return claims.getPayload().get(claimName, String.class); } @Override - public TokenHolder generateToken(String userId, String nickName) { - String accessToken = generateTokenInternal(userId, nickName, accessTokenExp); - String refreshToken = generateTokenInternal(userId, nickName, refreshTokenExp); - return new TokenHolder(accessToken, refreshToken); + public Collection getClaims(String token, String claimName) { + Jws claims = parseClaims(token); + var rawPayload = claims.getPayload().get(claimName, String.class); + if(rawPayload.isBlank()) + return new ArrayList<>(); + return Arrays.stream(rawPayload.split(",")).toList(); } - private String generateTokenInternal(String userId, String nickName, long expiration) { + private String generateTokenInternal(String userId, String nickName, + Collection authorities, long expiration) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date exp = new Date(nowMillis + expiration); @@ -59,7 +89,8 @@ private String generateTokenInternal(String userId, String nickName, long expira .subject(userId) .claims(Map.of( USER_ID, userId, - NICK_NAME, nickName + NICK_NAME, nickName, + ROLES, authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")) )) .issuedAt(now) .expiration(exp) @@ -67,33 +98,14 @@ private String generateTokenInternal(String userId, String nickName, long expira .compact(); } - private Jws parseClaims(String refreshToken) { - return Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(refreshToken); - } - - @Override - public void validateToken(String token) { + private Jws parseClaims(String token) { try { - parseClaims(token); - } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); } catch (JwtException e) { throw new AuthException("토큰 검증에 실패했습니다."); } } - - @Override - public String getClaim(String token, String claimName) { - try { - Jws claims = parseClaims(token); - return claims.getPayload().get(claimName, String.class); - } catch (JwtException e) { - throw new AuthException("토큰에서 클레임을 추출하는 데 실패했습니다."); - } catch (IllegalArgumentException e) { - throw new AuthException("올바르지 않은 토큰입니다."); - } - } } diff --git a/src/main/java/org/mandarin/booking/app/Log.java b/src/main/java/org/mandarin/booking/app/Log.java new file mode 100644 index 0000000..39e7990 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/Log.java @@ -0,0 +1,12 @@ +package org.mandarin.booking.app; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Log { + String scope() default "INFO"; +} diff --git a/src/main/java/org/mandarin/booking/app/LoggingAspect.java b/src/main/java/org/mandarin/booking/app/LoggingAspect.java new file mode 100644 index 0000000..c24f433 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/LoggingAspect.java @@ -0,0 +1,94 @@ +package org.mandarin.booking.app; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class LoggingAspect { + + @Around("@within(org.mandarin.booking.app.Log) || @annotation(org.mandarin.booking.app.Log)") + public Object logMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable { + Logger targetLogger = selectTargetLogger(joinPoint); + String level = resolveScope(joinPoint); + + LocalDateTime startAt = LocalDateTime.now(); + long startNs = System.nanoTime(); + String signature = joinPoint.getSignature().toLongString(); + + logAtLevel(targetLogger, level, "START {} at {}", signature, formatTime(startAt)); + + boolean success = false; + try { + Object result = joinPoint.proceed(); + success = true; + return result; + } catch (Throwable t) { + long elapsedMs = nanosToMillis(System.nanoTime() - startNs); + LocalDateTime endAt = LocalDateTime.now(); + if (targetLogger.isErrorEnabled()) { + targetLogger.error("END {} at {} ({} ms) with exception: {}", signature, formatTime(endAt), elapsedMs, t.toString()); + } + throw t; + } finally { + if (success) { + long elapsedMs = nanosToMillis(System.nanoTime() - startNs); + LocalDateTime endAt = LocalDateTime.now(); + logAtLevel(targetLogger, level, "END {} at {} ({} ms)", signature, formatTime(endAt), elapsedMs); + } + } + } + + private Logger selectTargetLogger(ProceedingJoinPoint joinPoint) { + MethodSignature ms = (MethodSignature) joinPoint.getSignature(); + Method method = ms.getMethod(); + Class targetClass = joinPoint.getTarget() != null ? joinPoint.getTarget().getClass() : method.getDeclaringClass(); + return LoggerFactory.getLogger(targetClass); + } + + private String resolveScope(ProceedingJoinPoint joinPoint) { + MethodSignature ms = (MethodSignature) joinPoint.getSignature(); + Method method = ms.getMethod(); + Class targetClass = joinPoint.getTarget() != null ? joinPoint.getTarget().getClass() : method.getDeclaringClass(); + try { + Method targetMethod = targetClass.getMethod(method.getName(), method.getParameterTypes()); + Log m = targetMethod.getAnnotation(Log.class); + if (m != null && !m.scope().isBlank()) return m.scope(); + } catch (NoSuchMethodException ignored) {} + Log c = targetClass.getAnnotation(Log.class); + String scope = c != null ? c.scope() : "INFO"; + return scope.isBlank() ? "INFO" : scope; + } + + private void logAtLevel(Logger logger, String level, String message, Object... args) { + String up = level.trim().toUpperCase(); + switch (up) { + case "TRACE" -> { if (logger.isTraceEnabled()) logger.trace(message, args); } + case "DEBUG" -> { if (logger.isDebugEnabled()) logger.debug(message, args); } + case "WARN" -> { if (logger.isWarnEnabled()) logger.warn(message, args); } + case "ERROR" -> { if (logger.isErrorEnabled()) logger.error(message, args); } + case "INFO" -> { if (logger.isInfoEnabled()) logger.info(message, args); } + default -> { if (logger.isInfoEnabled()) logger.info(message, args); } + } + } + + private static long nanosToMillis(long ns) { + return ns / 1_000_000L; + } + + private static String formatTime(LocalDateTime time) { + return time.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } +} diff --git a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java b/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java index f688b1d..6986254 100644 --- a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java +++ b/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java @@ -1,8 +1,8 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.mandarin.booking.domain.member.MemberException; -import org.mandarin.booking.infra.persist.MemberQueryRepository; import org.springframework.stereotype.Component; @Component diff --git a/src/main/java/org/mandarin/booking/app/MemberService.java b/src/main/java/org/mandarin/booking/app/MemberService.java index d6485b4..2322b34 100644 --- a/src/main/java/org/mandarin/booking/app/MemberService.java +++ b/src/main/java/org/mandarin/booking/app/MemberService.java @@ -1,11 +1,11 @@ package org.mandarin.booking.app; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.app.port.MemberRegisterer; -import org.mandarin.booking.infra.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/mandarin/booking/app/MovieService.java b/src/main/java/org/mandarin/booking/app/MovieService.java new file mode 100644 index 0000000..1ab43ac --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/MovieService.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.app; + +import static java.util.Objects.requireNonNull; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.MovieCommandRepository; +import org.mandarin.booking.app.port.MovieRegisterer; +import org.mandarin.booking.domain.movie.Movie; +import org.mandarin.booking.domain.movie.MovieCreateCommand; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MovieService implements MovieRegisterer { + private final MovieCommandRepository commandRepository; + @Override + public MovieRegisterResponse register(MovieRegisterRequest request) { + var command = MovieCreateCommand.from(request); + var movie = Movie.create(command); + var savedMovie = commandRepository.insert(movie); + return new MovieRegisterResponse(requireNonNull(savedMovie.getId())); + } +} diff --git a/src/main/java/org/mandarin/booking/app/TokenProvider.java b/src/main/java/org/mandarin/booking/app/TokenProvider.java deleted file mode 100644 index f07a821..0000000 --- a/src/main/java/org/mandarin/booking/app/TokenProvider.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.mandarin.booking.app; - -import org.mandarin.booking.infra.webapi.dto.TokenHolder; - -public interface TokenProvider { - TokenHolder generateToken(String refreshToken); - - TokenHolder generateToken(String userId, String nickName); - - String getClaim(String token, String claimName); - - void validateToken(String token); -} diff --git a/src/main/java/org/mandarin/booking/app/TokenUtils.java b/src/main/java/org/mandarin/booking/app/TokenUtils.java new file mode 100644 index 0000000..31a7a3d --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/TokenUtils.java @@ -0,0 +1,15 @@ +package org.mandarin.booking.app; + +import java.util.Collection; +import org.mandarin.booking.domain.member.TokenHolder; +import org.springframework.security.core.GrantedAuthority; + +public interface TokenUtils { + TokenHolder generateToken(String refreshToken); + + TokenHolder generateToken(String userId, String nickName, Collection authorities); + + String getClaim(String token, String claimName); + + Collection getClaims(String token, String claimName); +} diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java similarity index 80% rename from src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java index b0e8210..51f7b4d 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberCommandRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.member.Member; @@ -9,7 +9,7 @@ @Transactional @RequiredArgsConstructor public class MemberCommandRepository { - private final MemberJpaRepository jpaRepository; + private final MemberRepository jpaRepository; public Member insert(Member member) { return jpaRepository.save(member); diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java similarity index 87% rename from src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java index b641c62..bb23695 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberQueryRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -10,7 +10,7 @@ @Transactional(readOnly = true) @RequiredArgsConstructor public class MemberQueryRepository { - private final MemberJpaRepository jpaRepository; + private final MemberRepository jpaRepository; public boolean existsByEmail(String email) { return jpaRepository.existsByEmail(email); diff --git a/src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java b/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java similarity index 52% rename from src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java rename to src/main/java/org/mandarin/booking/app/persist/MemberRepository.java index 6be0f43..e7ddb6a 100644 --- a/src/main/java/org/mandarin/booking/infra/persist/MemberJpaRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java @@ -1,13 +1,15 @@ -package org.mandarin.booking.infra.persist; +package org.mandarin.booking.app.persist; import java.util.Optional; import org.mandarin.booking.domain.member.Member; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -interface MemberJpaRepository extends JpaRepository { +public interface MemberRepository extends Repository { boolean existsByUserId(String userId); boolean existsByEmail(String email); Optional findByUserId(String userId); + + Member save(Member member); } diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java new file mode 100644 index 0000000..b774c9a --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.app.persist; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.movie.Movie; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional +@RequiredArgsConstructor +public class MovieCommandRepository { + private final MovieRepository jpaRepository; + + public Movie insert(Movie movie){ + return jpaRepository.save(movie); + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java new file mode 100644 index 0000000..9526635 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.movie.Movie; +import org.springframework.data.repository.Repository; + +public interface MovieRepository extends Repository { + Movie save(Movie movie); +} diff --git a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java b/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java index 475ace3..5be54c9 100644 --- a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java +++ b/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java @@ -1,6 +1,6 @@ package org.mandarin.booking.app.port; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.domain.member.TokenHolder; public interface AuthUseCase { TokenHolder login(String userId, String password); diff --git a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java b/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java index 53b4d10..99867cf 100644 --- a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java +++ b/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java @@ -1,7 +1,7 @@ package org.mandarin.booking.app.port; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; public interface MemberRegisterer { MemberRegisterResponse register(MemberRegisterRequest request); diff --git a/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java b/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java new file mode 100644 index 0000000..b781160 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.port; + +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; + +public interface MovieRegisterer { + MovieRegisterResponse register(MovieRegisterRequest request); +} diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java b/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java similarity index 83% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java rename to src/main/java/org/mandarin/booking/domain/member/AuthRequest.java index ca89340..eb604cc 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/AuthRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/domain/member/Member.java b/src/main/java/org/mandarin/booking/domain/member/Member.java index 7618519..11aa500 100644 --- a/src/main/java/org/mandarin/booking/domain/member/Member.java +++ b/src/main/java/org/mandarin/booking/domain/member/Member.java @@ -1,6 +1,9 @@ package org.mandarin.booking.domain.member; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; +import java.util.ArrayList; +import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @@ -18,6 +21,9 @@ public class Member extends AbstractEntity { private String email; + @Convert(converter = MemberAuthorityConverter.class) + private List authorities = new ArrayList<>(); + public static Member create(MemberCreateCommand command, SecurePasswordEncoder securePasswordEncoder) { var member = new Member(); @@ -25,6 +31,7 @@ public static Member create(MemberCreateCommand command, member.userId = command.userId(); member.passwordHash = securePasswordEncoder.encode(command.password()); member.email = command.email(); + member.authorities.add(MemberAuthority.USER); return member; } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java new file mode 100644 index 0000000..649d5f4 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java @@ -0,0 +1,14 @@ +package org.mandarin.booking.domain.member; + +import org.springframework.security.core.GrantedAuthority; + +public enum MemberAuthority implements GrantedAuthority { + USER, + DISTRIBUTOR, + ADMIN; + + @Override + public String getAuthority() { + return "ROLE_" + name().toUpperCase(); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java new file mode 100644 index 0000000..2e46be4 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java @@ -0,0 +1,43 @@ +package org.mandarin.booking.domain.member; + + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Converter +public class MemberAuthorityConverter implements AttributeConverter, String> { + + private static final String DELIM = ","; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute.isEmpty()) { + return ""; + } + return attribute.stream() + .filter(Objects::nonNull) + .map(MemberAuthority::getAuthority) + .collect(Collectors.joining(DELIM)); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData.isBlank()) { + return new ArrayList<>(); + } + + return Arrays.stream(dbData.split(DELIM)) + .filter(s -> !s.isEmpty()) + .map(String::trim) + .map(String::toUpperCase) + .map(s -> s.substring(5)) + .map(MemberAuthority::valueOf) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java new file mode 100644 index 0000000..b302d75 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java @@ -0,0 +1,31 @@ +package org.mandarin.booking.domain.member; + +import java.util.Collection; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +public class MemberDetails implements UserDetails { + private final String userId; + private final String password; + private final Collection authorities; + + private MemberDetails(String userId, String password, Collection authorities) { + this.userId = userId; + this.password = password; + this.authorities = authorities; + } + + public static MemberDetails from(Member member) { + String userId = member.getUserId(); + String password = member.getPasswordHash(); + Collection authorities = member.getAuthorities(); + return new MemberDetails(userId, password, authorities); + } + + @Override + public String getUsername() { + return userId; + } +} diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java similarity index 92% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java rename to src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java index 84bffd3..e52d587 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java similarity index 77% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java rename to src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java index 197763d..9f0f76b 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/MemberRegisterResponse.java +++ b/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java @@ -1,6 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; - -import org.mandarin.booking.domain.member.Member; +package org.mandarin.booking.domain.member; public record MemberRegisterResponse( String userId, diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java b/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java similarity index 78% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java rename to src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java index a18835c..d6a1610 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/ReissueRequest.java +++ b/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java b/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java similarity index 60% rename from src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java rename to src/main/java/org/mandarin/booking/domain/member/TokenHolder.java index 2e4b396..f91c42a 100644 --- a/src/main/java/org/mandarin/booking/infra/webapi/dto/TokenHolder.java +++ b/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.infra.webapi.dto; +package org.mandarin.booking.domain.member; public record TokenHolder(String accessToken, String refreshToken) { } diff --git a/src/main/java/org/mandarin/booking/domain/movie/Movie.java b/src/main/java/org/mandarin/booking/domain/movie/Movie.java new file mode 100644 index 0000000..bf36e88 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/Movie.java @@ -0,0 +1,58 @@ +package org.mandarin.booking.domain.movie; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Movie extends AbstractEntity { + private String title; + + private String director; + + private Integer runtimeMinutes; + + @Enumerated(EnumType.STRING) + private Genre genre; + + private LocalDate releaseDate; + + @Enumerated(EnumType.STRING) + private Rating rating; + + private String synopsis; + + private String posterUrl; + + + @ElementCollection + @CollectionTable(name = "movie_cast", joinColumns = @JoinColumn(name = "movie_id")) + @Column(name = "actor_name") + private Set casts = new HashSet<>(); + + public static Movie create(MovieCreateCommand command) { + return new Movie(command.getTitle(), command.getDirector(), command.getRuntimeMinutes(), command.getGenre(), + command.getReleaseDate(), command.getRating(), command.getSynopsis(), command.getPosterUrl(), command.getCasts()); + } + + public enum Genre { + ACTION, DRAMA, COMEDY, THRILLER, ROMANCE, SF, FANTASY, HORROR, ANIMATION, DOCUMENTARY, ETC + } + + public enum Rating { + ALL, AGE12, AGE15, AGE18 + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java new file mode 100644 index 0000000..e9d8a7f --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java @@ -0,0 +1,49 @@ +package org.mandarin.booking.domain.movie; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import lombok.Getter; +import org.mandarin.booking.domain.movie.Movie.Genre; +import org.mandarin.booking.domain.movie.Movie.Rating; + +@Getter +public class MovieCreateCommand { + private final String title; + private final Genre genre; + private final int runtimeMinutes; + private final String director; + private final String synopsis; + private final String posterUrl; + private final LocalDate releaseDate; + private final Rating rating; + private final Set casts; + + private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String director, String synopsis, + String posterUrl, LocalDate releaseDate, Rating rating, Set casts) { + this.title = title; + this.genre = genre; + this.runtimeMinutes = runtimeMinutes; + this.director = director; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + this.releaseDate = releaseDate; + this.rating = rating; + this.casts = casts; + } + + public static MovieCreateCommand from(MovieRegisterRequest request) { + + return new MovieCreateCommand( + request.title(), + Genre.valueOf(request.genre()), + request.runtimeMinutes(), + request.director(), + request.synopsis(), + request.posterUrl(), + LocalDate.parse(request.releaseDate()), + Rating.valueOf(request.rating()), + new HashSet<>(request.casts()) + ); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java new file mode 100644 index 0000000..4a2ea4a --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java @@ -0,0 +1,33 @@ +package org.mandarin.booking.domain.movie; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.util.List; + +public record MovieRegisterRequest( + @NotBlank(message = "Title must not be blank") + String title, + + @NotBlank(message = "Director must not be blank") + String director, + + @NotNull(message = "Runtime minutes must not be null") + @Min(value = 0, message = "Runtime minutes must be non-negative") + Integer runtimeMinutes, + + @NotBlank(message = "Genre must not be blank") + String genre, + + @NotBlank(message = "Release date must not be blank") + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "releaseDate must be yyyy-MM-dd") + String releaseDate, + + @NotBlank(message = "Rating must not be blank") + String rating, + + String synopsis, + String posterUrl, + List casts) { +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java new file mode 100644 index 0000000..496e724 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java @@ -0,0 +1,4 @@ +package org.mandarin.booking.domain.movie; + +public record MovieRegisterResponse(Long movieId) { +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/package-info.java b/src/main/java/org/mandarin/booking/domain/movie/package-info.java new file mode 100644 index 0000000..baa5f7e --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/movie/package-info.java @@ -0,0 +1,3 @@ +@NonNullApi +package org.mandarin.booking.domain.movie; +import org.springframework.lang.NonNullApi; diff --git a/src/main/java/org/mandarin/booking/domain/package-info.java b/src/main/java/org/mandarin/booking/domain/package-info.java new file mode 100644 index 0000000..d524781 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package org.mandarin.booking.domain; + +import org.springframework.lang.NonNullApi; diff --git a/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java deleted file mode 100644 index 638c44b..0000000 --- a/src/main/java/org/mandarin/booking/infra/security/SecurityConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.mandarin.booking.infra.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class SecurityConfig { - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST,"/api/members").permitAll() - .requestMatchers("/api/auth/login").permitAll() - .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers("/test/**").permitAll() - .anyRequest().authenticated() - ) - .csrf(csrf -> csrf.disable()); - return http.build(); - } - -} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 88af482..70689f7 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,8 +11,18 @@ spring: dialect: org.hibernate.dialect.MySQL8Dialect hibernate: ddl-auto: create + docker: + compose: + lifecycle-management: start_only + web.resources.add-mappings: false jwt: token: secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGUtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGU access: 600000 refresh: 1800000 + +logging: + level: + org.springframework.boot.web.servlet: INFO + org.springframework.security.web.FilterChainProxy: TRACE + diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index f90a9a7..8bf4f10 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -15,9 +15,14 @@ spring: format_sql: true show_sql: true + web.resources.add-mappings: false jwt: token: secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGUtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWV4YW1wbGU access: 600000 refresh: 1800000 + +logging: + level: + org.springframework.security: TRACE diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java index fd6cd9a..e3be1fd 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtils.java @@ -2,45 +2,104 @@ import static org.mandarin.booking.fixture.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import org.mandarin.booking.infra.persist.MemberCommandRepository; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import java.util.Collection; +import java.util.List; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.MemberAuthority; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.test.util.ReflectionTestUtils; public class IntegrationTestUtils { private final TestRestTemplate testRestTemplate; private final MemberCommandRepository memberRepository; + private final TokenUtils tokenUtils; private final SecurePasswordEncoder securePasswordEncoder; private final ObjectMapper objectMapper; public IntegrationTestUtils(TestRestTemplate testRestTemplate, MemberCommandRepository memberRepository, + TokenUtils tokenUtils, SecurePasswordEncoder securePasswordEncoder, ObjectMapper objectMapper) { this.testRestTemplate = testRestTemplate; this.memberRepository = memberRepository; + this.tokenUtils = tokenUtils; this.securePasswordEncoder = securePasswordEncoder; this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } - public Member insertDummyMember(String userId, String password) { - return memberRepository.insert( - Member.create(new MemberCreateCommand( - generateNickName(), - userId, - password, - generateEmail() - ), securePasswordEncoder) - ); + public TestResult get(String path) { + return new TestResult(path, null) + .setContext(testRestTemplate, objectMapper); } public TestResult post(String path, T request) { return new TestResult(path, request) .setContext(testRestTemplate, objectMapper); } + + public String getValidRefreshToken() { + var member = insertDummyMember(generateUserId(), generatePassword()); + return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()).refreshToken(); + } + + public String getAuthToken() { + var member = this.insertDummyMember(); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } + + public String getAuthToken(Member member) { + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } + + public TokenHolder getUserToken(String userId, String nickname, Collection authorities) { + return tokenUtils.generateToken(userId, nickname, authorities); + } + + public Member insertDummyMember(String userId, String password) { + var command = new MemberCreateCommand( + generateNickName(), + userId, + password, + generateEmail() + ); + return memberRepository.insert( + Member.create(command, securePasswordEncoder) + ); + } + + public Member insertDummyMember(String userId, String nickName, List authorities) { + var command = new MemberCreateCommand( + nickName, + userId, + generatePassword(), + generateEmail() + ); + var member = Member.create(command, securePasswordEncoder); + ReflectionTestUtils.setField(member, "authorities", authorities); + return memberRepository.insert( + member + ); + } + + public Member insertDummyMember() { + return this.insertDummyMember(generateUserId(), generatePassword()); + } + + public String getAuthToken(MemberAuthority ...memberAuthority) { + var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); + return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()).accessToken(); + } } diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java index 043f967..e5d9472 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java +++ b/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java @@ -1,16 +1,15 @@ package org.mandarin.booking; import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import com.fasterxml.jackson.core.type.TypeReference; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mandarin.booking.domain.member.Member; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.ParameterizedTypeReference; @Disabled @IntegrationTest @@ -29,7 +28,7 @@ void post_echo_success( // Act var response = integrationUtils.post("/test/echo", payload) - .assertSuccess(new ParameterizedTypeReference>() {}); + .assertSuccess(new TypeReference>() {}); // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); @@ -48,9 +47,8 @@ void insertDummyMember_and_verify_exists( String password = "P@ssw0rd!"; // save member using utils - Member saved = integrationUtils.insertDummyMember(userId, password); + var saved = integrationUtils.insertDummyMember(userId, password); assertThat(saved).isNotNull(); - assertThat(saved.getId()).isNotNull(); // Act Map request = Map.of("userId", userId); diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/src/test/java/org/mandarin/booking/TestConfig.java index 1864842..19c4f94 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/src/test/java/org/mandarin/booking/TestConfig.java @@ -1,7 +1,8 @@ package org.mandarin.booking; import com.fasterxml.jackson.databind.ObjectMapper; -import org.mandarin.booking.infra.persist.MemberCommandRepository; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.app.persist.MemberCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -13,8 +14,10 @@ public class TestConfig { @Bean public IntegrationTestUtils integrationTestUtils(@Autowired TestRestTemplate testRestTemplate, @Autowired MemberCommandRepository memberRepository, + @Autowired TokenUtils tokenUtils, @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper) { - return new IntegrationTestUtils(testRestTemplate, memberRepository, securePasswordEncoder, objectMapper); + return new IntegrationTestUtils(testRestTemplate, memberRepository, tokenUtils, securePasswordEncoder, + objectMapper); } } diff --git a/src/test/java/org/mandarin/booking/TestOnlyController.java b/src/test/java/org/mandarin/booking/TestOnlyController.java index d77d153..ef14c46 100644 --- a/src/test/java/org/mandarin/booking/TestOnlyController.java +++ b/src/test/java/org/mandarin/booking/TestOnlyController.java @@ -1,7 +1,7 @@ package org.mandarin.booking; import java.util.Map; -import org.mandarin.booking.infra.persist.MemberQueryRepository; +import org.mandarin.booking.app.persist.MemberQueryRepository; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/src/test/java/org/mandarin/booking/TestResult.java index f460f1a..f5c4218 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/src/test/java/org/mandarin/booking/TestResult.java @@ -1,171 +1,155 @@ package org.mandarin.booking; -import static java.util.Objects.requireNonNull; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import org.mandarin.booking.infra.webapi.ErrorResponse; -import org.mandarin.booking.infra.webapi.SuccessResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import org.mandarin.booking.adapter.webapi.ApiResponse; +import org.mandarin.booking.adapter.webapi.ApiStatus; +import org.mandarin.booking.adapter.webapi.ErrorResponse; +import org.mandarin.booking.adapter.webapi.SuccessResponse; import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; - public class TestResult { private final String path; private final Object request; - - private TestRestTemplate testRestTemplate; - private ObjectMapper objectMapper; + private final Map headers = new HashMap<>(); public TestResult(String path, Object request) { this.path = path; this.request = request; } - public SuccessResponse assertSuccess(Class responseBodyType) { - String body = postForBody(); - - if (body == null || body.isBlank() || responseBodyType == Void.class) { - return new SuccessResponse<>(SUCCESS, null); - } + private TestRestTemplate testRestTemplate; + private ObjectMapper objectMapper; - JsonNode node = parseJson(body); - assertSuccessStatus(requireNonNull(node), body); + public ApiResponse assertSuccess(Class responseType) { + var response = readSuccessResponse( + getResponse(), + responseType + ); - JavaType targetType = buildSuccessType(responseBodyType); + if (response == null) { + throw new AssertionError("Expected SUCCESS response, but got: " + response); + } else if (response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); + } - return deserializeSuccess(body, targetType, "SuccessResponse<" + responseBodyType.getSimpleName() + ">"); + return response; } - public SuccessResponse assertSuccess(ParameterizedTypeReference responseTypeRef) { - String body = postForBody(); - - if (body == null || body.isBlank()) { - return new SuccessResponse<>(SUCCESS, null); + public ApiResponse assertSuccess(TypeReference typeReference) { + var response = readSuccessResponse( + getResponse(), + typeReference + ); + if (response == null) { + throw new AssertionError("Expected SUCCESS response, but got: " + response); + } else if (response.getStatus() != ApiStatus.SUCCESS) { + throw new AssertionError("Expected SUCCESS response, but got Error response: " + response); } - JsonNode node = parseJson(body); - assertSuccessStatus(requireNonNull(node), body); - - JavaType targetType = buildSuccessType(responseTypeRef); - - return deserializeSuccess(body, targetType, "SuccessResponse with parameterized type"); + return response; } public ErrorResponse assertFailure() { - String body = postForBody(); - - JsonNode node = parseJson(body); - assertErrorStatus(requireNonNull(node), body); + var response = readErrorResponse(); + if (response == null) { + throw new AssertionError("Expected Error response, but got: " + response); + }else if (response.getStatus() == ApiStatus.SUCCESS) { + throw new AssertionError("Expected Error response, but got SUCCESS: " + response); + } + return response; + } - return deserializeError(body); + public TestResult withHeader(String headerName, String headerValue) { + headers.put(headerName, headerValue); + return this; } TestResult setContext(TestRestTemplate testRestTemplate, ObjectMapper objectMapper) { this.testRestTemplate = testRestTemplate; - this.objectMapper = objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + this.objectMapper = objectMapper; return this; } - private String postForBody() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - var resp = testRestTemplate.postForEntity(path, new HttpEntity<>(request, headers), String.class); - System.out.println(resp.getStatusCode()); - System.out.println(resp.getBody()); - return resp.getBody(); - } - - - private JsonNode parseJson(String body) { + private ApiResponse readSuccessResponse(String raw, Class dataType) { try { - return objectMapper.readTree(body); - } catch (Exception e) { - fail("[Deserialization Failure] Expected JSON with status but failed to parse.\nActual response body: " - + body, e); - return null; // Unreachable, added to satisfy compiler + if(objectMapper.readTree(raw).has("message")){ + fail("Expected SuccessResponse but got ErrorResponse: " + raw); + } + var wrapperType = objectMapper.getTypeFactory() + .constructParametricType(SuccessResponse.class, dataType); + return objectMapper.readValue(raw, wrapperType); + } catch (JsonProcessingException primary) { + try { + if (dataType == String.class) { + @SuppressWarnings("unchecked") + T data = (T) raw; + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } + if (dataType == Void.class) { + return new SuccessResponse<>(ApiStatus.SUCCESS, null); + } + T data = objectMapper.readValue(raw, dataType); + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } catch (Exception fallback) { + fail("Failed to parse SuccessResponse with data type " + dataType.getName() + ": " + primary.getMessage(), primary); + return null; + } } } - private void assertSuccessStatus(JsonNode node, String rawBody) { - JsonNode statusNode = node.get("status"); - String status = statusNode == null ? null : statusNode.asText(); - if (status == null) { - fail("[Assertion Failure] Expected a success response but 'status' field is missing.\nActual response body: " - + rawBody); - return; - } - if (!"SUCCESS".equals(status)) { - fail("[Assertion Failure] Expected SUCCESS but was '" + status - + "'. Use assertFailure() for error responses.\nActual response body: " + rawBody); - } - } - - private void assertErrorStatus(JsonNode node, String rawBody) { - JsonNode statusNode = node.get("status"); - String status = statusNode == null ? null : statusNode.asText(); - if (status == null) { - fail("[Assertion Failure] Expected an error response but 'status' field is missing.\nActual response body: " - + rawBody); - return; - } - if ("SUCCESS".equals(status)) { - fail("[Assertion Failure] Expected an error response but got SUCCESS. Use assertSuccess() for successful responses.\nActual response body: " - + rawBody); - } - } - - private JavaType buildSuccessType(Class responseBodyType) { - return objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, responseBodyType); - } - - private JavaType buildSuccessType(ParameterizedTypeReference responseTypeRef) { - var typeFactory = objectMapper.getTypeFactory(); - JavaType innerType = typeFactory.constructType(responseTypeRef.getType()); - return typeFactory.constructParametricType(SuccessResponse.class, innerType); - } - - private SuccessResponse deserializeSuccess(String body, JavaType targetType, String expectationDesc) { + private SuccessResponse readSuccessResponse(String raw, TypeReference typeRef) { try { - return objectMapper.readValue(body, targetType); - } catch (Exception e) { - fail("[Deserialization Failure] Expected " + expectationDesc - + " but failed to deserialize.\nActual response body: " + body, e); - return null; // Unreachable, added to satisfy compiler + var inner = objectMapper.getTypeFactory().constructType(typeRef); + var wrapper = objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, inner); + return objectMapper.readValue(raw, wrapper); + } catch (JsonProcessingException primary) { + try { + if ("java.lang.String".equals(typeRef.getType().getTypeName())) { + @SuppressWarnings("unchecked") + T data = (T) raw; + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } + T data = objectMapper.readValue(raw, typeRef); + return new SuccessResponse<>(ApiStatus.SUCCESS, data); + } catch (Exception fallback) { + fail("Failed to parse SuccessResponse with data type " + + typeRef.getType() + ": " + primary.getMessage(), primary); + return null; + } } } - private ErrorResponse deserializeError(String body) { + private ErrorResponse readErrorResponse() { + var response = getResponse(); try { - return objectMapper.readValue(body, ErrorResponse.class); + if(objectMapper.readTree(response).has("data")){ + fail("Expected ErrorResponse but got SuccessResponse: " + response); + } + return objectMapper.readValue(response, ErrorResponse.class); } catch (Exception e) { - fail("[Deserialization Failure] Expected ErrorResponse but failed to deserialize.\nActual response body: " - + body, e); - return null; // Unreachable, added to satisfy compiler + fail("Failed to parse ErrorResponse: " + e.getMessage(), e); + return null; } } - private String describeActualDataType(JsonNode dataNode) { - if (dataNode.isObject()) { - List fieldNames = new ArrayList<>(); - dataNode.fieldNames().forEachRemaining(fieldNames::add); - Collections.sort(fieldNames); - return "an object with fields " + fieldNames; - } - if (dataNode.isArray()) { - return "an array"; + private String getResponse() { + var httpHeaders = new HttpHeaders(); + for (Entry entry : headers.entrySet()) { + httpHeaders.add(entry.getKey(), entry.getValue()); } - // NodeType을 소문자로 변환하여 "a string value", "a number value" 등으로 표현합니다. - return "a " + dataNode.getNodeType().toString().toLowerCase() + " value"; + return (request == null) + ? testRestTemplate.exchange(path, GET, new HttpEntity<>(httpHeaders), String.class).getBody() + : testRestTemplate.exchange(path, POST, new HttpEntity<>(request, httpHeaders), String.class).getBody(); } } diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java new file mode 100644 index 0000000..dd81059 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java @@ -0,0 +1,77 @@ +package org.mandarin.booking.adapter.security; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.InsufficientAuthenticationException; + +@ExtendWith(MockitoExtension.class) +class CustomAuthenticationEntryPointTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final CustomAuthenticationEntryPoint entryPoint = new CustomAuthenticationEntryPoint(objectMapper); + + @BeforeEach + void setUp() { + objectMapper.registerModule(new JavaTimeModule()); + } + + @Test + @DisplayName("When exception attribute exists, it overrides authException message and writes UNAUTHORIZED json") + void commence_withRequestAttribute() throws IOException { + // Arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + var sw = new java.io.StringWriter(); + var writer = new java.io.PrintWriter(sw, true); + + when(request.getAttribute(eq("exception"))).thenReturn(new Exception("test")); + when(response.getWriter()).thenReturn(writer); + + var authException = new InsufficientAuthenticationException("auth failed msg"); + + // Act + entryPoint.commence(request, response, authException); + writer.flush(); + + // Assert + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + } + + @Test + @DisplayName("When exception attribute is null, use AuthenticationException message") + void commence_withAuthExceptionMessage() throws IOException { + // Arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + var sw = new java.io.StringWriter(); + var writer = new java.io.PrintWriter(sw, true); + + when(request.getAttribute(eq("exception"))).thenReturn(null); + when(response.getWriter()).thenReturn(writer); + + var authException = new InsufficientAuthenticationException("auth failed msg"); + + // Act + entryPoint.commence(request, response, authException); + writer.flush(); + + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java new file mode 100644 index 0000000..530bc62 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java @@ -0,0 +1,34 @@ +package org.mandarin.booking.adapter.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@IntegrationTest +class CustomAuthenticationProviderTest { + @Autowired + CustomAuthenticationProvider provider; + + @Test + void supports() { + var isSupported = provider.supports(CustomMemberAuthenticationToken.class); + assertThat(isSupported).isTrue(); + } + + @Test + void supportsFailure(){ + var isSupported = provider.supports(String.class); + assertThat(isSupported).isFalse(); + } + + @Test + void shouldNotAuthenticateNonCustomToken() { + var exception = assertThrows(RuntimeException.class, + () -> provider.authenticate(new UsernamePasswordAuthenticationToken("user", "pass"))); + assertTrue(exception.getMessage().contains("Unsupported authentication type")); + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java new file mode 100644 index 0000000..c7ffd57 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java @@ -0,0 +1,175 @@ +package org.mandarin.booking.adapter.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController; +import org.mandarin.booking.adapter.security.JwtFilterTest.TestAuthController.TestSecurityConfig; +import org.mandarin.booking.app.TokenUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@IntegrationTest +@Import({TestSecurityConfig.class, TestAuthController.class}) +class JwtFilterTest { + private static final String PONG_WITHOUT_AUTH = "pong without auth"; + private static final String PONG_WITH_AUTH = "pong with auth"; + private static final String WITH_USER_ROLE = "pong with user role"; + + + @Test + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { + // Act & Assert + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); + } + + @Test + void withAuth(@Autowired IntegrationTestUtils testUtils) { + var accessToken = testUtils.getAuthToken(); + + // Act & Assert + var response = testUtils.get( + "/test/with-auth" + ) + .withHeader("Authorization", accessToken) + .assertSuccess(String.class); +// + assertThat(response.getStatus()).isEqualTo(SUCCESS); + assertThat(response.getData()).isEqualTo(PONG_WITH_AUTH); + } + + @Test + void failToAuth(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + + @Test + void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidBearer = "Bearer invalid-token"; + + // Act + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", invalidBearer) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + + @Test + void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var member = testUtils.insertDummyMember("dummy", "dummy", List.of()); + var accessToken = testUtils.getAuthToken(member); + + // Act + var response = testUtils.get("/test/with-user-role") + .withHeader("Authorization", accessToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + assertThat(response.getData()).isEqualTo("Access Denied"); + } + + @Test + void blankTokenWillFailToAuth( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var accessToken = "Bearer "; + + // Act + var response = testUtils.get("/test/with-auth") + .withHeader("Authorization", accessToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("토큰이 비어있습니다."); + } + + @RestController + @RequestMapping("/test") + static class TestAuthController { + @GetMapping("/without-auth") + public String ping() { + return PONG_WITHOUT_AUTH; + } + + @GetMapping("/with-auth") + public String pingWithAuth() { + return PONG_WITH_AUTH; + } + + @GetMapping("/with-user-role") + public String pingWithUserRole() { + return WITH_USER_ROLE; + } + + @TestConfiguration + @EnableMethodSecurity + static class TestSecurityConfig { + + @Bean + @Order(0) + SecurityFilterChain testOnlyEndpoints( + HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler, TokenUtils tokenUtils, + AuthenticationProvider authenticationProvider) throws Exception { + http + .securityMatcher("/test/**") + .authorizeHttpRequests(a -> a + .requestMatchers("/test/without-auth").permitAll() + .requestMatchers("/test/with-auth").authenticated() + .requestMatchers("/test/with-user-role").hasAuthority("USER") + ) + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(new JwtFilter(tokenUtils, authenticationProvider::authenticate), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + } + + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..c67c589 --- /dev/null +++ b/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java @@ -0,0 +1,23 @@ +package org.mandarin.booking.adapter.webapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; + +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +class GlobalExceptionHandlerTest { + + @Test + void endpointNotFound(@Autowired IntegrationTestUtils testUtils){ + // Act + var request = testUtils.get("/not-found") + .assertFailure(); + + // Assert + assertThat(request.getStatus()).isEqualTo(NOT_FOUND); + } +} diff --git a/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java b/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java new file mode 100644 index 0000000..ee8972c --- /dev/null +++ b/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java @@ -0,0 +1,192 @@ +package org.mandarin.booking.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.IntegrationTest; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@IntegrationTest +@Import(LoggingAspectTest.TestConfig.class) +class LoggingAspectTest { + + private ListAppender listAppender; + + @Autowired + LoggingAspectTest.SampleService bean; + + @Autowired + LoggingAspectTest.BlankMethodOnlyService blankMethodOnlyService; + + @BeforeEach + void setUp() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + logger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + logger.detachAppender(listAppender); + } + + private static ListAppender attachAppender(Class target) { + Logger logger = (Logger) LoggerFactory.getLogger(target); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(Level.TRACE); + return la; + } + + private static void detachAppender(Class target, ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(target); + logger.detachAppender(la); + } + + @Configuration + @Import({AopAutoConfiguration.class, LoggingAspect.class}) + static class TestConfig { + @Bean + SampleService sampleLoggedService() { + return new SampleLoggedService(); + } + @Bean + BlankMethodOnlyService blankMethodOnlyService() { return new MethodBlankOnlyService(); } + } + + interface SampleService { + String doWork(); + String doTraced(); + String fail(); + String doWarn(); + String doErrorLevel(); + String doCustom(); + } + + interface BlankMethodOnlyService { String blankOnly(); } + + static class MethodBlankOnlyService implements BlankMethodOnlyService { + @Log(scope = " ") + public String blankOnly() { return "blank"; } + } + + @Log(scope = "DEBUG") + static class SampleLoggedService implements SampleService { + public String doWork() { return "ok"; } + @Log(scope = "TRACE") + public String doTraced() { return "traced"; } + public String fail() { throw new IllegalStateException("boom"); } + @Log(scope = "WARN") + public String doWarn() { return "warned"; } + @Log(scope = "ERROR") + public String doErrorLevel() { return "erred"; } + @Log(scope = "CUSTOM") + public String doCustom() { return "custom"; } + } + + @Test + @DisplayName("Class-level @Log produces START and END logs at configured level, method inherits when not annotated") + void classLevelLog_startEnd() { + SampleService s = bean; + String res = s.doWork(); + assertThat(res).isEqualTo("ok"); + + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.DEBUG); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doWork"); + assertThat(events.get(1).getLevel()).isEqualTo(Level.DEBUG); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("("); + } + + @Test + @DisplayName("Method-level @Log overrides class level") + void methodLevelOverrides() { + SampleService s = bean; + String res = s.doTraced(); + assertThat(res).isEqualTo("traced"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.TRACE); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doTraced"); + } + + @Test + @DisplayName("On exception, END is logged at error with exception info") + void exceptionLogging() { + SampleService s = bean; + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(1).getLevel()).isEqualTo(Level.ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("with exception").contains("IllegalStateException"); + } + + @Test + @DisplayName("Method annotated with WARN logs at WARN") + void warnLevelMethod() { + SampleService s = bean; + String res = s.doWarn(); + assertThat(res).isEqualTo("warned"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.WARN); + } + + @Test + @DisplayName("Method annotated with ERROR logs START/END at ERROR on success path") + void errorLevelMethod_successful() { + SampleService s = bean; + String res = s.doErrorLevel(); + assertThat(res).isEqualTo("erred"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.ERROR); + assertThat(events.get(1).getLevel()).isEqualTo(Level.ERROR); + } + + @Test + @DisplayName("Unknown scope falls back to INFO (default branch)") + void unknownScopeDefaultsToInfo() { + SampleService s = bean; + String res = s.doCustom(); + assertThat(res).isEqualTo("custom"); + List events = listAppender.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.INFO); + } + + @Test + @DisplayName("Blank method scope with no class annotation falls back to INFO") + void blankMethodScopeFallsBackToInfo() { + // Attach specific appender for MethodBlankOnlyService class + ListAppender la = attachAppender(MethodBlankOnlyService.class); + try { + String res = blankMethodOnlyService.blankOnly(); + assertThat(res).isEqualTo("blank"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(Level.INFO); + } finally { + detachAppender(MethodBlankOnlyService.class, la); + } + } +} diff --git a/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java b/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java new file mode 100644 index 0000000..4dfec37 --- /dev/null +++ b/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java @@ -0,0 +1,26 @@ +package org.mandarin.booking.arch; + + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.library.Architectures; + +@AnalyzeClasses(packages = "org.mandarin.booking", importOptions = ImportOption.DoNotIncludeTests.class) +public class HexagonalArchitectureTest { + + @ArchTest + void hexagonalArchitectureTest(JavaClasses classes) { + Architectures + .layeredArchitecture() + .consideringAllDependencies() + .layer("adapter").definedBy("..adapter..") + .layer("application").definedBy("..app..") + .layer("domain").definedBy("..domain..") + .whereLayer("adapter").mayNotBeAccessedByAnyLayer() + .whereLayer("application").mayOnlyBeAccessedByLayers("adapter") + .whereLayer("domain").mayOnlyBeAccessedByLayers("adapter", "application") + .check(classes); + } +} diff --git a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index ea8ff52..519eed0 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -6,9 +6,9 @@ import static org.mandarin.booking.JwtTestUtils.getTokenClaims; import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; -import static org.mandarin.booking.infra.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; -import static org.mandarin.booking.infra.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; import io.jsonwebtoken.security.Keys; import java.util.Date; @@ -19,9 +19,9 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.AuthRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.AuthRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; diff --git a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index 01315e9..15894a2 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -3,24 +3,26 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.JwtTestUtils.assertJwtFormat; import static org.mandarin.booking.JwtTestUtils.getExpiration; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.member.MemberAuthority.USER; import static org.mandarin.booking.fixture.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.fixture.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; -import static org.mandarin.booking.infra.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.infra.webapi.ApiStatus.SUCCESS; -import static org.mandarin.booking.infra.webapi.ApiStatus.UNAUTHORIZED; import io.jsonwebtoken.security.Keys; import java.util.Date; +import java.util.List; import javax.crypto.SecretKey; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.app.TokenProvider; -import org.mandarin.booking.infra.webapi.dto.ReissueRequest; -import org.mandarin.booking.infra.webapi.dto.TokenHolder; +import org.mandarin.booking.app.TokenUtils; +import org.mandarin.booking.domain.member.ReissueRequest; +import org.mandarin.booking.domain.member.TokenHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; @@ -30,14 +32,10 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_200을_응답한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired IntegrationTestUtils testUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -53,14 +51,10 @@ public class POST_specs { @Test void 올바른_refresh_token으로_요청하면_새로운_access_token과_refresh_token을_발급해_응답한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired IntegrationTestUtils testUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -79,15 +73,11 @@ public class POST_specs { @Test void 응답받은_access_toke과_refresh_toke은_유효한_JWT_형식이다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider, @Value("${jwt.token.secret}") String key ) { // Arrange SecretKey secretKey = Keys.hmacShaKeyFor(key.getBytes()); - var userId = generateUserId(); - var nickName = generateNickName(); - testUtils.insertDummyMember(userId, generatePassword()); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = testUtils.getValidRefreshToken(); var request = new ReissueRequest(validRefreshToken); // Act @@ -155,12 +145,11 @@ public class POST_specs { class ReissueShortToken{ @Test void 만료된_refresh_token으로_요청하면_401_Unauthorize가_발생한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired IntegrationTestUtils testUtils ) throws InterruptedException { // Arrange - tokenProvider.generateToken(generateUserId(), generateNickName()); - var request = new ReissueRequest(getValidRefreshToken(tokenProvider, generateUserId(), generateNickName())); + var request = new ReissueRequest( + testUtils.getValidRefreshToken()); Thread.sleep(100); //TODO 2025 08 18 16:47:00 : 시간 의존적 코드가 테스트 속도에 영향을 미치지 않도록 개선 필요 // Act @@ -178,12 +167,10 @@ class ReissueShortToken{ @Test void 존재하지_않는_사용자의_refresh_token을_요청하면_401_Unauthorize가_발생한다( @Autowired IntegrationTestUtils testUtils, - @Autowired TokenProvider tokenProvider + @Autowired TokenUtils tokenUtils ) { // Arrange - var userId = generateUserId(); - var nickName = generateNickName(); - var validRefreshToken = getValidRefreshToken(tokenProvider, userId, nickName); + var validRefreshToken = tokenUtils.generateToken(generateUserId(), generateNickName(), List.of(USER)).refreshToken(); var request = new ReissueRequest(validRefreshToken); // user 생성 안함 @@ -198,10 +185,4 @@ class ReissueShortToken{ // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } - - - - private static String getValidRefreshToken(TokenProvider tokenProvider, String userId, String nickName) { - return tokenProvider.generateToken(userId, nickName).refreshToken(); - } } diff --git a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 7ea66f5..8cb021a 100644 --- a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -10,9 +10,9 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mandarin.booking.IntegrationTest; import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.infra.persist.MemberQueryRepository; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterRequest; -import org.mandarin.booking.infra.webapi.dto.MemberRegisterResponse; +import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.fixture.MemberFixture.NicknameGenerator; import org.mandarin.booking.fixture.MemberFixture.PasswordGenerator; @@ -20,7 +20,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; @IntegrationTest -@DisplayName("POST /api/members") +@DisplayName("POST /api/member") public class POST_specs { @Test @@ -33,7 +33,7 @@ public class POST_specs { // Act var response = testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -52,7 +52,7 @@ public class POST_specs { // Act testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -77,7 +77,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -106,14 +106,14 @@ public class POST_specs { ); testUtils.post( - "/api/members", + "/api/member", existingRequest ) .assertSuccess(MemberRegisterResponse.class); // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -135,7 +135,7 @@ public class POST_specs { email ); testUtils.post( - "/api/members", + "/api/member", existingRequest ) .assertSuccess(MemberRegisterResponse.class); @@ -149,7 +149,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -179,7 +179,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertFailure(); @@ -205,7 +205,7 @@ public class POST_specs { // Act var res = testRestTemplate.postForEntity( - "/api/members", + "/api/member", request, Void.class ); @@ -226,7 +226,7 @@ public class POST_specs { // Act var response = testUtils.post( - "/api/members", + "/api/member", request ) .assertSuccess(MemberRegisterResponse.class); diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java new file mode 100644 index 0000000..56274e9 --- /dev/null +++ b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java @@ -0,0 +1,183 @@ +package org.mandarin.booking.webapi.movie; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.domain.movie.MovieRegisterRequest; +import org.mandarin.booking.domain.movie.MovieRegisterResponse; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("POST /api/movie") +public class POST_specs { + + @Test + void 올바른_요청을_보내면_status가_SUCCESS이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + + var request = generateMovieRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", authToken) + .assertSuccess(MovieRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + + @Test + void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + } + + @ParameterizedTest + @MethodSource("org.mandarin.booking.webapi.movie.POST_specs#nullOrBlankElementRequests") + void title_director_runtimeMinutes_genre_releaseDate_rating이_비어있으면_BAD_REQUEST이다( + MovieRegisterRequest request, + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void runtimeMinutes은_0_미만이면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + + // Act + var response = testUtils.post( + "/api/movie", + generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", "2010-07-21", "AGE12") + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void releaseDate는_yyyy_MM_dd_형태를_준수하지_않으면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + // 잘못된 날짜 형식 + var request = generateMovieRegisterRequest( + "영화 제목", "감독 이름", 148, "SF", "21-07-2010", "AGE12" + ); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 올바른_요청을_보내면_응답_본문에_movieId가_존재한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = generateMovieRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/movie", + request + ) + .withHeader("Authorization", authToken) + .assertSuccess(MovieRegisterResponse.class); + + // Assert + assertThat(response.getData().movieId()).isNotNull(); + } + + static List nullOrBlankElementRequests(){ + return List.of( + generateMovieRegisterRequest("", "감독 이름", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", ""), + generateMovieRegisterRequest(null, "감독 이름", 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", null, 148, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, null, "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", null, "SF", "2010-07-21", "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), + generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", null) + ); + } + + + private static MovieRegisterRequest generateMovieRegisterRequest(String title, String director, Integer runtimeMinutes, + String genre, String releaseDate, String rating) { + return new MovieRegisterRequest( + title, + director, + runtimeMinutes, + genre, + releaseDate, + rating, + "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "https://example.com/posters/inception.jpg", + List.of("레오나르도 디카프리오", + "조셉 고든레빗", + "엘렌 페이지") + ); + } + + private static MovieRegisterRequest generateMovieRegisterRequest() { + return generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..1f0955d --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline