diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index cfe4c2b..1c1a3bc 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -9,9 +9,9 @@ name: Java CI with Gradle on: push: - branches: ["main"] + branches: [ "main" ] pull_request: - branches: ["main"] + branches: [ "main" ] jobs: build: diff --git a/.gitignore b/.gitignore index 88538ea..cb650c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -# Gradle 캐시·빌드 정보 .gradle/ -/build +**/build +build .idea .aiassistant/rules/AGENTS.md -/src/docs/ diff --git a/AGENTS.md b/AGENTS.md index b270c9f..5611fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,62 +9,67 @@ Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) 본 문서는 사람용 README가 아닌, 코드 에이전트(코딩 봇)를 위한 실행 지침서입니다. 이 저장소의 실제 파일과 문서를 스캔해 확인된 사실만 기술합니다. 확인 불가능한 항목은 "확인 불가"로 명시합니다. ## 1) Title & Scope + - 목적: 이 파일은 에이전트를 위한 지침서입니다. 개발/운영 작업 자동화 시 준수 규칙과 명령을 제공합니다. - 적용 범위: 단일 레포지토리(single repo), 단일 모듈 구조 - - 근거: `settings.gradle`에 `rootProject.name = 'booking'`만 정의되어 있으며 서브프로젝트 선언 없음. + - 근거: `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` + - 도메인 개요: `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'`) + - 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 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 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 태스크 설정) + - 기본: `./gradlew test` (JUnit5, Spring Boot Test, Mockito, Security Test, ArchUnit 포함) + - 테스트 시 프로필: `test` (근거: `build.gradle` test 태스크 설정) - 코드 분석/품질 도구 - - SpotBugs 사용 (근거: `build.gradle` → `com.github.spotbugs` 플러그인). 일반 태스크: `spotbugsMain`, `spotbugsTest`. - - 포매터/린터(Spotless/Checkstyle 등): 확인 불가 (저장소 내 설정/플러그인 없음). + - 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` + - 근거: `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` 층 규칙 참조. + - 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 문서). + - 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`, `ShowRepository`). - - 트랜잭션 경계는 app 서비스 (예: `ShowCommandRepository`에 `@Transactional`). + - 의존성: `spring-boot-starter-data-jpa`, DB: MySQL(H2 for test), P6Spy (근거: `build.gradle`) + - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `ShowRepository`). + - 트랜잭션 경계는 app 서비스 (예: `ShowCommandRepository`에 `@Transactional`). - 보안(Spring Security/JWT) - - JWT 파싱/인증: `adapter/security/JwtFilter`가 Authorization 헤더 `Bearer ` 처리 (근거 파일). - - SecurityFilterChain 설정과 우선순위: `SecurityConfig` - - @Order(1) `apiChain` → 직접 구현한 엔드포인트에 대한 인증/인가 설정. `api/**` 규율을 준수함. - - @Order(2) `publicChain` → 그 외 경로 permitAll. - - AuthenticationProvider: `adapter/security/CustomAuthenticationProvider` (구체 로직은 소스 참조). + - JWT 파싱/인증: `adapter/security/JwtFilter`가 Authorization 헤더 `Bearer ` 처리 (근거 파일). + - SecurityFilterChain 설정과 우선순위: `SecurityConfig` + - @Order(1) `apiChain` → 직접 구현한 엔드포인트에 대한 인증/인가 설정. `api/**` 규율을 준수함. + - @Order(2) `publicChain` → 그 외 경로 permitAll. + - AuthenticationProvider: `adapter/security/CustomAuthenticationProvider` (구체 로직은 소스 참조). - 메시징/캐시/검색 - - Kafka/Redis/Elasticsearch 사용: 확인 불가 (관련 의존성/설정 없음). + - Kafka/Redis/Elasticsearch 사용: 확인 불가 (관련 의존성/설정 없음). 자세한 규칙은 `docs/specs/policy/application.md`를 준수하십시오. @@ -80,18 +85,21 @@ Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) - 테스트 메서드: 시나리오 기반 네이밍 (예: 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) 설명 @@ -102,6 +110,7 @@ Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) 작업 시 반드시 `docs/specs/policy/application.md` 규칙을 따르십시오. ## 6) Commands (신뢰 가능한 명령만) + - Gradle Wrapper 사용을 강제합니다. ```bash @@ -125,33 +134,37 @@ docker compose up -d ``` 근거 파일/경로: + - `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/* 업데이트 반영 + - [ ] 모든 테스트 통과 (`./gradlew test`) + - [ ] SpotBugs 통과 (`./gradlew spotbugsMain spotbugsTest`) + - [ ] 아키텍처 테스트 통과 (`HexagonalArchitectureTest`) + - [ ] docs/specs/* 업데이트 반영 - 코드 스타일 점검 - - [ ] 코드가 글 읽듯 자연스럽게 읽히는가? (네이밍, 메서드 길이, 역할 분리 확인) - - [ ] 불필요한 주석이 없는가? 필요한 주석만 인터페이스/복잡 규칙에 존재하는가? - - [ ] 모든 테스트/SpotBugs/아키텍처 테스트 통과 여부 + - [ ] 코드가 글 읽듯 자연스럽게 읽히는가? (네이밍, 메서드 길이, 역할 분리 확인) + - [ ] 불필요한 주석이 없는가? 필요한 주석만 인터페이스/복잡 규칙에 존재하는가? + - [ ] 모든 테스트/SpotBugs/아키텍처 테스트 통과 여부 ## 8) Environments + - 프로필 - - local: 기본 활성 (근거: `application.yml`), MySQL 설정 및 JWT 시크릿 포함 (근거: `application-local.yml`). - - test: H2 메모리 DB, JPA DDL auto create, JWT 설정 포함 (근거: `application-test.yml`). - - prod: 파일 존재하나 내용 비어 있음 → 확인 불가. + - 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에는 예시 값이 존재하나, 운영에서는 환경변수로 덮어쓰기를 권장. + - `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): 확인 불가. @@ -159,6 +172,7 @@ docker compose up -d - 배포(AWS/K8s): 설정/스크립트 부재 → 확인 불가. 향후 TODO(사람이 보완): + - prod 프로필 구성 및 비밀 주입 전략 정의 - CI 파이프라인 도입(.github/workflows) - DB 마이그레이션 도구 채택 및 규약 수립 @@ -166,92 +180,97 @@ docker compose up -d - 코드 포매터 도입 여부 결정(Spotless/Checkstyle 등) ## 10) Appendix + - 모듈/계층 의존 개요(텍스트) - - adapter(webapi, security) → app\(ports, services, persist adapters) → domain - - domain은 프레임워크 비의존, app은 domain에만 의존, adapter는 app의 포트에 의존 + - 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/...` — 단위/아키텍처/통합 테스트 스위트 + - `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/**` + - 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 사용. + - 단위: 도메인/서비스 단위 로직 검증, 외부 의존 모킹(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`, `show_register.md`, `reissue.md` - - 정책 문서: `docs/specs/policy/.md` - - 근거: `docs/specs/policy/application.md`, `authentication.md`, `authorization.md`, `test.md` - - 도메인 설계: `docs/specs/domain.md` + - API 스펙: `docs/specs/api/_.md` 또는 엔드포인트 의미가 드러나는 snake_case 파일명 사용 + - 근거: `docs/specs/api/login.md`, `member_register.md`, `show_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`, `show_register.md`, `reissue.md` - 3) 응답 섹션 구성 - - 상태코드 명시, 응답 JSON 예시 제공(필수 필드 포함) - 4) 테스트 섹션 구성 - - 체크박스 형태의 수용 기준 리스트(`[x]/[ ]`) 사용. 실제 테스트 코드와 동기화합니다. - - 체크리스트 항목은 구체적 조건/결과를 포함합니다. + 1) 제목 생략 가능(현재 파일들은 섹션 위주). 최상단에 섹션 "요청/응답/테스트"를 포함합니다. + 2) 요청 섹션 구성 + - 메서드, 경로, 헤더 코드블록, 본문 JSON 예시(필수 필드 포맷 포함) + - 실행 가능한 curl 예시를 제공 + - 근거 예시: `docs/specs/api/login.md`, `member_register.md`, `show_register.md`, `reissue.md` + 3) 응답 섹션 구성 + - 상태코드 명시, 응답 JSON 예시 제공(필수 필드 포함) + 4) 테스트 섹션 구성 + - 체크박스 형태의 수용 기준 리스트(`[x]/[ ]`) 사용. 실제 테스트 코드와 동기화합니다. + - 체크리스트 항목은 구체적 조건/결과를 포함합니다. - 도메인 문서 템플릿 - - 상단 개요(프로젝트/도메인 목적) - - 도메인 모델 섹션 - - 각 Aggregate/Entity 별로: 표제 → 역할 설명(이탤릭), 속성 목록, 행위 목록, 관련 타입 목록 - - 근거 예시: `docs/specs/domain.md` + - 상단 개요(프로젝트/도메인 목적) + - 도메인 모델 섹션 + - 각 Aggregate/Entity 별로: 표제 → 역할 설명(이탤릭), 속성 목록, 행위 목록, 관련 타입 목록 + - 근거 예시: `docs/specs/domain.md` - 정책 문서 템플릿 - - 번호 있는 대제목(1., 2., 3. …)을 사용하여 규칙을 체계화 - - 레이어/의존/포트/보안/테스트 등 주제별 세부 항목을 불릿으로 상세화 - - 실제 코드/설정 파일 경로를 근거로 명시 - - "확인 불가"를 명확히 표기해 향후 TODO를 남김 - - 근거 예시: `docs/specs/policy/application.md`, `docs/specs/policy/test.md` + - 번호 있는 대제목(1., 2., 3. …)을 사용하여 규칙을 체계화 + - 레이어/의존/포트/보안/테스트 등 주제별 세부 항목을 불릿으로 상세화 + - 실제 코드/설정 파일 경로를 근거로 명시 + - "확인 불가"를 명확히 표기해 향후 TODO를 남김 + - 근거 예시: `docs/specs/policy/application.md`, `docs/specs/policy/test.md` - 링크/근거 표기 규칙 - - 저장소 상대 경로 링크를 사용합니다(예: `build.gradle`, `src/main/...`). - - 문서 말미 또는 섹션 말미에 "근거" 목록을 배치하여 신뢰 가능한 출처를 나열합니다. - - 정책/가이드 문서에는 규칙 옆에 괄호로 근거를 병기해도 됩니다. + - 저장소 상대 경로 링크를 사용합니다(예: `build.gradle`, `src/main/...`). + - 문서 말미 또는 섹션 말미에 "근거" 목록을 배치하여 신뢰 가능한 출처를 나열합니다. + - 정책/가이드 문서에는 규칙 옆에 괄호로 근거를 병기해도 됩니다. - 표기/형식 규칙 - - 헤더 레벨은 H2(##)부터 사용해 문서 내 구조를 안정적으로 유지합니다. - - 코드/JSON/명령은 fenced code block 사용. JSON 예시에는 실제 키를 포함하되 민감정보는 예시 값으로 대체. - - 에러/예외/상태코드는 명시적으로 표기(예: `400 Bad Request`, `401 Unauthorized`). - - 체크박스는 `[x]`(충족) / `[ ]`(미충족) 형식으로 유지. + - 헤더 레벨은 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` + - 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` + - 실제 yml의 키/값/프로필을 그대로 반영하고, 운영 비밀은 "환경변수로 주입"이라고만 적습니다. + - 근거: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` - 금지 사항 - - 추측성 서술 금지("추정/아마도" 금지). 알 수 없는 항목은 "확인 불가". - - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. + - 추측성 서술 금지("추정/아마도" 금지). 알 수 없는 항목은 "확인 불가". + - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. - 예시 스니펫 스타일 - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/show_register.md`의 curl 예시) @@ -260,6 +279,7 @@ docker compose up -d --- 근거 스니펫 링크/파일 경로 요약: + - `build.gradle`: 플러그인/의존성/테스트 태스크 - `settings.gradle`: 단일 모듈 확인 - `src/main/resources/application*.yml`: 프로필/환경 설정 diff --git a/README.md b/README.md index c4bde25..c8be2a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# booking — 공연 예매 시스템 (개요 중심 README) - -- 저장소 루트: 단일 모듈(Spring Boot) 프로젝트 +# booking — 공연 예매 시스템 --- @@ -12,9 +10,11 @@ - 도메인 개요: [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 문서 하단의 체크리스트를 참고하세요. @@ -22,19 +22,25 @@ --- ## 3. 아키텍처 개요 (Hexagonal) -헥사고날 아키텍처를 적용하여 다음과 같은 레이어 규칙을 따릅니다. -- domain: 순수한 도메인 모델과 비즈니스 규칙. 프레임워크 의존 금지. -- app: 유스케이스 서비스, 입력/출력 포트, 트랜잭션 경계, 검증, AOP. -- adapter: 웹 API, 보안, 영속성 등 외부 인터페이스. +헥사고날 아키텍처를 일부 차용한 모듈 구조를 채택했습니다. 각 모듈의 책임은 다음과 같습니다. + +- internal: 애플리케이션 내부의 생태계를 관리한다. 직접적인 비즈니스 관리영역이 아닌 '애플리케이션' 자체를 관리한다. 로그 설정, web 설정, 보안 설정등 비즈니스 요구사항을 직접적으로 나타내지 않는 + 구현들이 존재한다. +- external: 외부 세계와의 통신을 담당한다. 도메인 로직은 물론 애플리케이션과도 완전 독립적인 모듈이다. MQ, STMP등등에 대한 기능의 구현이 존재한다. +- domain: 비즈니스 영역의 핵심이 되는 영역이다. 비즈니스를 해결하기 위한 도메인 그 자체를 의미하며 도메인 개념을 로직으로 풀어나가는 영역이다. Entity와 통신 객체들이 여기에 해당한다. +- common: 공통코드들을 관리한다. 파급효과가 가장 큰 영역인 만큼 라이브러리 사용을 방지하고 POJO 스타일을 원칙으로 한다. 상수와 type object들이 존재한다. +- application: 모든 영역들을 통합해 애플리케이션을 만들어 관리한다. Spring boot의 main class가 존재하며, 각 모듈들을 통합해 비즈니스 요구사항을 해결한다. 비즈니스 로직을 해결하는 + 영역과 이를 전달하는 영역으로 대부분의 Service 영역과 Controller영역, 그리고 통합 테스트가 존재한다. + +- 근거와 세부 규칙 -근거와 세부 규칙 - 정책 문서: [docs/specs/policy](docs/specs/policy) -- 레이어 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 레이어 테스트: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.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}/*` + - 도메인: `domain/src/main/java/org/mandarin/booking/domain/*` + - 앱/포트/영속 어댑터: `application/src/main/java/org/mandarin/booking/app/*` + - 웹/보안 어댑터: `application/src/main/java/org/mandarin/booking/adapter/{webapi,security}/*` 텍스트 다이어그램: [Controllers/Security/External] → adapter → app(ports, services) → domain @@ -44,34 +50,40 @@ - Show (Aggregate Root): 제목, 감독, 장르, 상영시간, 개봉일, 등급, 줄거리, 포스터URL, 출연진 등. 팩토리/커맨드 기반 생성. - Member (Aggregate Root): 닉네임, userId, email, passwordHash, 권한 목록. 비밀번호 해시 일치 검증. +- Hall (Entity): 상영관 이름, 좌석 배치(행/열), 총 좌석 수. 자세한 속성과 규칙: [docs/specs/domain.md](docs/specs/domain.md) --- ## 5. 기술 스택과 선택 근거 + - 추후 작성 예정 선택 이유(요지) + - Hexagonal: 테스트 용이성과 변경 격리를 위해 계층 경계를 명확히. 또한, 추후 모듈화 or MSA 전환시 이점을 위해 애플리케이션 아키텍처를 영역에 따라 구분. - Spring Security + JWT: 무상태(stateless) API 인증과 확장성. - JPA + RDB(H2/MySQL): 표준 ORM과 빠른 테스트 사이클. +- Spring Modulith: 명확한 Bounded Context 경계 분리 및 추수 MSA 전환 대비 --- ## 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` +- 모듈 구조 테스트: 모듈간 의존관계 테스트 + - 예시: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java` Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profiles, JUnit Platform, ByteBuddy javaagent). --- ## 7. 보안 개요 + - 필터 기반 JWT 인증: `JwtFilter`가 Authorization `Bearer `을 파싱해 SecurityContext 설정. - 경로별 권한: `SecurityConfig`의 `@Order(1) apiChain` - 차후 추가 작성 @@ -82,18 +94,20 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile --- ## 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) + - 근거: `application-local.yml`, Docker Compose: [compose.yaml](application/src/main/resources/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) @@ -104,19 +118,24 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile --- ## 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) + - 인증/인가 정책 문서 + 구체화: [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) + +- Spring Boot/Java/Gradle + 버전: [build.gradle](application/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` +- 보안 설정/필터: `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/application/.gitignore b/application/.gitignore new file mode 100644 index 0000000..152badd --- /dev/null +++ b/application/.gitignore @@ -0,0 +1,2 @@ +src/main/resources/static/docs/ +spy.log diff --git a/application/build.gradle b/application/build.gradle new file mode 100644 index 0000000..2e221c4 --- /dev/null +++ b/application/build.gradle @@ -0,0 +1,143 @@ +plugins { + id 'com.epages.restdocs-api-spec' version '0.18.2' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +configurations { + byteBuddyAgent + asciidoctorExt +} + +dependencyManagement { + imports { + mavenBom 'org.springframework.modulith:spring-modulith-bom:1.4.3' + } +} + +dependencies { + api(project(':domain')) + api(project(':internal')) + api(project(':external')) + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + byteBuddyAgent 'net.bytebuddy:byte-buddy-agent:1.17.6' + + runtimeOnly 'org.springframework.modulith:spring-modulith-runtime' + implementation 'org.springframework.modulith:spring-modulith-starter-core' + implementation 'org.springframework.modulith:spring-modulith-starter-jpa' + implementation 'org.springframework.modulith:spring-modulith-events-api:1.4.3' + testImplementation 'org.springframework.modulith:spring-modulith-starter-test' + + testImplementation 'io.rest-assured:rest-assured' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' +} + +def snippetsDir = layout.buildDirectory.dir("generated-snippets") +def generatedIndexDir = layout.buildDirectory.dir("tmp/asciidoc") +def asciidocOutputDir = layout.buildDirectory.dir("asciidoc") + +tasks.register('prepareSnippetsDir') { + doLast { + snippetsDir.get().asFile.mkdirs() + } +} + +tasks.named('test') { + jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}" + outputs.dir(snippetsDir) +} + +tasks.register('generateIndexAdoc') { + dependsOn tasks.named('test'), tasks.named('prepareSnippetsDir') + outputs.file(generatedIndexDir.map { it.file("index.adoc") }) + doLast { + def genDir = generatedIndexDir.get().asFile + def snip = snippetsDir.get().asFile + + genDir.mkdirs() + + def ops = snip.listFiles()?.findAll { it.isDirectory() } ?: [] + + def groupKey = { String name -> name.contains(' - ') ? name.substring(0, name.indexOf(' - ')) : name } + + def grouped = ops.groupBy { dir -> groupKey(dir.name) }.sort { a, b -> a.key <=> b.key } + + def includes = ['curl-request.adoc', + 'http-request.adoc', + 'http-response.adoc', + 'path-parameters.adoc', + 'query-parameters.adoc', + 'request-parameters.adoc', + 'request-fields.adoc', + 'response-fields.adoc', + 'links.adoc'] + + def sb = new StringBuilder() + sb << "= API 문서\n:toc: left\n:sectnums:\n\n" + + grouped.each { grp, dirs -> + sb << "== ${grp}\n\n" + + dirs.findAll { it.name == grp }.each { dir -> + includes.each { inc -> + def f = new File(dir, inc) + if (f.exists()) { + def caption = inc.replace('.adoc', '').replace('-', ' ') + sb << ".${caption}\ninclude::{snippets}/" << dir.name << "/" << inc << "[]\n\n" + } + } + } + + dirs.findAll { it.name != grp } + .sort { it.name } + .each { dir -> + def methodTitle = dir.name.substring(grp.length() + " - ".length()) + sb << "=== ${methodTitle}\n\n" + includes.each { inc -> + def f = new File(dir, inc) + if (f.exists()) { + def caption = inc.replace('.adoc', '').replace('-', ' ') + sb << ".${caption}\ninclude::{snippets}/" << dir.name << "/" << inc << "[]\n\n" + } + } + } + } + + new File(genDir, "index.adoc").text = sb.toString() + } +} + +tasks.named('asciidoctor') { + dependsOn tasks.named('prepareSnippetsDir'), tasks.named('generateIndexAdoc') + inputs.dir(snippetsDir) + configurations = [project.configurations.asciidoctorExt] + sourceDir generatedIndexDir.get().asFile + sources { include 'index.adoc' } + baseDirFollowsSourceFile() + outputDir asciidocOutputDir.get().asFile + attributes 'snippets': snippetsDir.get().asFile.absolutePath +} + +tasks.named('build') { + dependsOn tasks.named('asciidoctor') +} + +tasks.named('bootJar') { + dependsOn tasks.named('asciidoctor') + from(asciidocOutputDir.get()) { into 'static/docs' } +} + +tasks.named('clean') { + doFirst { + delete asciidocOutputDir.get().asFile + delete generatedIndexDir.get().asFile + } +} diff --git a/src/main/java/org/mandarin/booking/BookingApplication.java b/application/src/main/java/org/mandarin/booking/BookingApplication.java similarity index 100% rename from src/main/java/org/mandarin/booking/BookingApplication.java rename to application/src/main/java/org/mandarin/booking/BookingApplication.java diff --git a/src/main/java/org/mandarin/booking/app/BCryptSecurePasswordEncoder.java b/application/src/main/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoder.java similarity index 84% rename from src/main/java/org/mandarin/booking/app/BCryptSecurePasswordEncoder.java rename to application/src/main/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoder.java index ef46a77..f85cf7f 100644 --- a/src/main/java/org/mandarin/booking/app/BCryptSecurePasswordEncoder.java +++ b/application/src/main/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoder.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.adapter.security; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -7,7 +7,7 @@ @Component @RequiredArgsConstructor -public class BCryptSecurePasswordEncoder implements SecurePasswordEncoder { +class BCryptSecurePasswordEncoder implements SecurePasswordEncoder { private final BCryptPasswordEncoder bCryptPasswordEncoder; @Override diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java similarity index 73% rename from src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java rename to application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java index 0da27ce..2ad51a0 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java +++ b/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java @@ -1,18 +1,19 @@ 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.AuthException; +import org.mandarin.booking.adapter.CustomMemberAuthenticationToken; +import org.mandarin.booking.app.member.MemberQueryRepository; 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.security.core.userdetails.User; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor -public class CustomAuthenticationProvider implements AuthenticationProvider { +class CustomAuthenticationProvider implements AuthenticationProvider { private final MemberQueryRepository queryRepository; @Override @@ -34,7 +35,11 @@ public Authentication authenticate(Authentication authentication) throws Authent } private void specifyToken(CustomMemberAuthenticationToken token, Member member) { - MemberDetails details = MemberDetails.from(member); + var details = User.builder() + .username(member.getUserId()) + .authorities(member.getParsedAuthorities()) + .password(member.getPasswordHash()) + .build(); token.setDetails(details);// set user details token.setAuthenticated(true); } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java similarity index 68% rename from src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java rename to application/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java index a940a93..038afe1 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/AuthController.java @@ -1,10 +1,10 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.app.port.AuthUseCase; +import org.mandarin.booking.TokenHolder; +import org.mandarin.booking.app.member.AuthUseCase; 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; @@ -12,15 +12,15 @@ @RestController @RequestMapping("/api/auth") -public record AuthController(AuthUseCase authUsecase) { +record AuthController(AuthUseCase authUsecase) { @PostMapping("/login") - public TokenHolder login(@RequestBody @Valid AuthRequest request) { + TokenHolder login(@RequestBody @Valid AuthRequest request) { return authUsecase.login(request.userId(), request.password()); } @PostMapping("/reissue") - public TokenHolder reissue(@RequestBody @Valid ReissueRequest request) { + TokenHolder reissue(@RequestBody @Valid ReissueRequest request) { return authUsecase.reissue(request.refreshToken()); } } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java similarity index 72% rename from src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java rename to application/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java index 7483a15..dae95d0 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/MemberController.java @@ -1,9 +1,9 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.domain.member.MemberRegisterResponse; -import org.mandarin.booking.app.port.MemberRegisterer; +import org.mandarin.booking.app.member.MemberRegisterer; import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,10 +11,10 @@ @RestController @RequestMapping("/api/member") -public record MemberController(MemberRegisterer memberRegisterer) { +record MemberController(MemberRegisterer memberRegisterer) { @PostMapping - public MemberRegisterResponse register(@RequestBody @Valid MemberRegisterRequest request){ + MemberRegisterResponse register(@RequestBody @Valid MemberRegisterRequest request) { return memberRegisterer.register(request); } } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java similarity index 71% rename from src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java rename to application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index f6480ba..2a98764 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,7 +1,7 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import org.mandarin.booking.app.port.ShowRegisterer; +import org.mandarin.booking.app.show.ShowRegisterer; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; @@ -13,15 +13,15 @@ @RestController @RequestMapping("/api/show") -public record ShowController(ShowRegisterer showRegisterer) { +record ShowController(ShowRegisterer showRegisterer) { @PostMapping - public ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { + ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { return showRegisterer.register(request); } @PostMapping("/schedule") - public ShowScheduleRegisterResponse registerSchedule(@RequestBody @Valid ShowScheduleRegisterRequest request) { + ShowScheduleRegisterResponse registerSchedule(@RequestBody @Valid ShowScheduleRegisterRequest request) { return showRegisterer.registerSchedule(request); } } diff --git a/src/main/java/org/mandarin/booking/app/QuerydslConfig.java b/application/src/main/java/org/mandarin/booking/app/QuerydslConfig.java similarity index 94% rename from src/main/java/org/mandarin/booking/app/QuerydslConfig.java rename to application/src/main/java/org/mandarin/booking/app/QuerydslConfig.java index a622bfb..95f81d4 100644 --- a/src/main/java/org/mandarin/booking/app/QuerydslConfig.java +++ b/application/src/main/java/org/mandarin/booking/app/QuerydslConfig.java @@ -7,7 +7,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -public class QuerydslConfig { +class QuerydslConfig { @PersistenceContext private EntityManager entityManager; diff --git a/src/main/java/org/mandarin/booking/app/AuthService.java b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java similarity index 82% rename from src/main/java/org/mandarin/booking/app/AuthService.java rename to application/src/main/java/org/mandarin/booking/app/member/AuthService.java index ae122a7..73b0fa9 100644 --- a/src/main/java/org/mandarin/booking/app/AuthService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java @@ -1,12 +1,11 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.MemberQueryRepository; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; -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.AuthException; +import org.mandarin.booking.TokenHolder; +import org.mandarin.booking.adapter.TokenUtils; import org.mandarin.booking.domain.member.Member; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.stereotype.Service; @Service @@ -27,8 +26,9 @@ public TokenHolder login(String userId, String password) { @Override public TokenHolder reissue(String refreshToken) { var userId = tokenUtils.getClaim(refreshToken, "userId"); - if(!queryRepository.existsByUserId(userId)) + if (!queryRepository.existsByUserId(userId)) { throw new AuthException("회원이 존재하지 않습니다"); + } return tokenUtils.generateToken(refreshToken); } diff --git a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java b/application/src/main/java/org/mandarin/booking/app/member/AuthUseCase.java similarity index 59% rename from src/main/java/org/mandarin/booking/app/port/AuthUseCase.java rename to application/src/main/java/org/mandarin/booking/app/member/AuthUseCase.java index 5be54c9..a55b3da 100644 --- a/src/main/java/org/mandarin/booking/app/port/AuthUseCase.java +++ b/application/src/main/java/org/mandarin/booking/app/member/AuthUseCase.java @@ -1,8 +1,9 @@ -package org.mandarin.booking.app.port; +package org.mandarin.booking.app.member; -import org.mandarin.booking.domain.member.TokenHolder; +import org.mandarin.booking.TokenHolder; public interface AuthUseCase { TokenHolder login(String userId, String password); + TokenHolder reissue(String refreshToken); } diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java similarity index 91% rename from src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java index 51f7b4d..f666975 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.member.Member; diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java similarity index 94% rename from src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java index bb23695..d91c351 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.member; import java.util.Optional; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java similarity index 74% rename from src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java index 6986254..88897a6 100644 --- a/src/main/java/org/mandarin/booking/app/MemberRegisterValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java @@ -1,7 +1,6 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.MemberQueryRepository; import org.mandarin.booking.domain.member.MemberException; import org.springframework.stereotype.Component; @@ -11,12 +10,14 @@ public class MemberRegisterValidator { private final MemberQueryRepository queryRepository; void checkDuplicateEmail(String email) { - if(queryRepository.existsByEmail(email)) + if (queryRepository.existsByEmail(email)) { throw new MemberException("이미 존재하는 이메일입니다: " + email); + } } void checkDuplicateUserId(String userId) { - if(queryRepository.existsByUserId(userId)) + if (queryRepository.existsByUserId(userId)) { throw new MemberException("이미 존재하는 회원입니다: " + userId); + } } } diff --git a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterer.java similarity index 85% rename from src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberRegisterer.java index 99867cf..9d4b0ea 100644 --- a/src/main/java/org/mandarin/booking/app/port/MemberRegisterer.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterer.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.port; +package org.mandarin.booking.app.member; import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.mandarin.booking.domain.member.MemberRegisterResponse; diff --git a/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRepository.java similarity index 73% rename from src/main/java/org/mandarin/booking/app/persist/MemberRepository.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberRepository.java index e7ddb6a..685972f 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MemberRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberRepository.java @@ -1,10 +1,10 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.member; import java.util.Optional; import org.mandarin.booking.domain.member.Member; import org.springframework.data.repository.Repository; -public interface MemberRepository extends Repository { +interface MemberRepository extends Repository { boolean existsByUserId(String userId); boolean existsByEmail(String email); diff --git a/src/main/java/org/mandarin/booking/app/MemberService.java b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java similarity index 88% rename from src/main/java/org/mandarin/booking/app/MemberService.java rename to application/src/main/java/org/mandarin/booking/app/member/MemberService.java index 2322b34..02083fc 100644 --- a/src/main/java/org/mandarin/booking/app/MemberService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java @@ -1,13 +1,11 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.MemberCommandRepository; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; -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.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.MemberRegisterRequest; +import org.mandarin.booking.domain.member.MemberRegisterResponse; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.stereotype.Service; @Service diff --git a/application/src/main/java/org/mandarin/booking/app/member/package-info.java b/application/src/main/java/org/mandarin/booking/app/member/package-info.java new file mode 100644 index 0000000..e8b3356 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/member/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface("member") +package org.mandarin.booking.app.member; + +import org.springframework.modulith.NamedInterface; diff --git a/src/main/java/org/mandarin/booking/app/package-info.java b/application/src/main/java/org/mandarin/booking/app/package-info.java similarity index 100% rename from src/main/java/org/mandarin/booking/app/package-info.java rename to application/src/main/java/org/mandarin/booking/app/package-info.java diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java similarity index 91% rename from src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java rename to application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java index c5fb793..3344547 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.show; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.show.Show; diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java similarity index 97% rename from src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java rename to application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 6e90912..b397472 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; diff --git a/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java b/application/src/main/java/org/mandarin/booking/app/show/ShowRegisterer.java similarity index 92% rename from src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java rename to application/src/main/java/org/mandarin/booking/app/show/ShowRegisterer.java index eb4b66c..cc9a038 100644 --- a/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowRegisterer.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.port; +package org.mandarin.booking.app.show; import jakarta.validation.Valid; import org.mandarin.booking.domain.show.ShowRegisterRequest; diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowRepository.java similarity index 69% rename from src/main/java/org/mandarin/booking/app/persist/ShowRepository.java rename to application/src/main/java/org/mandarin/booking/app/show/ShowRepository.java index e152cfa..9dfe79e 100644 --- a/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowRepository.java @@ -1,10 +1,10 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.show; import java.util.Optional; import org.mandarin.booking.domain.show.Show; import org.springframework.data.repository.Repository; -public interface ShowRepository extends Repository { +interface ShowRepository extends Repository { Show save(Show show); boolean existsByTitle(String title); diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java similarity index 71% rename from src/main/java/org/mandarin/booking/app/ShowService.java rename to application/src/main/java/org/mandarin/booking/app/show/ShowService.java index efc3c53..e75e755 100644 --- a/src/main/java/org/mandarin/booking/app/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -1,9 +1,9 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.app.show; + +import static java.util.Objects.requireNonNull; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.ShowCommandRepository; -import org.mandarin.booking.app.persist.ShowQueryRepository; -import org.mandarin.booking.app.port.ShowRegisterer; +import org.mandarin.booking.app.venue.HallValidator; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.ShowException; @@ -12,8 +12,6 @@ import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; -import org.mandarin.booking.domain.venue.HallException; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service @@ -21,7 +19,7 @@ public class ShowService implements ShowRegisterer { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; - private final ApplicationEventPublisher applicationEventPublisher; + private final HallValidator hallValidator; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { @@ -31,7 +29,7 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { checkDuplicateTitle(show.getTitle()); var saved = commandRepository.insert(show); - return new ShowRegisterResponse(saved.getId()); + return new ShowRegisterResponse(requireNonNull(saved.getId())); } @Override @@ -39,14 +37,14 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest var show = queryRepository.findById(request.showId()); var hallId = request.hallId(); - checkHallExist(hallId); + hallValidator.checkHallExist(hallId); checkConflictSchedule(hallId, request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); show.registerSchedule(hallId, command); var saved = commandRepository.insert(show); - return new ShowScheduleRegisterResponse(saved.getId()); + return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } private void checkDuplicateTitle(String title) { @@ -60,13 +58,5 @@ private void checkConflictSchedule(Long hallId, ShowScheduleRegisterRequest requ throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } } - - private void checkHallExist(Long hallId) { - var event = new HallExistCheckEvent(hallId); - applicationEventPublisher.publishEvent(event); - if (!event.isExist()) { - throw new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다."); - } - } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/package-info.java b/application/src/main/java/org/mandarin/booking/app/show/package-info.java new file mode 100644 index 0000000..19ade8f --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface("show") +package org.mandarin.booking.app.show; + +import org.springframework.modulith.NamedInterface; diff --git a/src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java similarity index 90% rename from src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java rename to application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java index 2389f62..5f18b0f 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.venue; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.venue.Hall; diff --git a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java similarity index 90% rename from src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java rename to application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java index 0bdb28a..34b4b40 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.venue; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java b/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java similarity index 60% rename from src/main/java/org/mandarin/booking/app/persist/HallRepository.java rename to application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java index c34fd54..a1bd6bd 100644 --- a/src/main/java/org/mandarin/booking/app/persist/HallRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java @@ -1,9 +1,9 @@ -package org.mandarin.booking.app.persist; +package org.mandarin.booking.app.venue; import org.mandarin.booking.domain.venue.Hall; import org.springframework.data.repository.Repository; -public interface HallRepository extends Repository { +interface HallRepository extends Repository { Hall save(Hall hall); boolean existsById(Long id); diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallService.java b/application/src/main/java/org/mandarin/booking/app/venue/HallService.java new file mode 100644 index 0000000..113519d --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/venue/HallService.java @@ -0,0 +1,18 @@ +package org.mandarin.booking.app.venue; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.venue.HallException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class HallService implements HallValidator { + private final HallQueryRepository queryRepository; + + @Override + public void checkHallExist(Long hallId) { + if (!queryRepository.existsById(hallId)) { + throw new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다."); + } + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java b/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java new file mode 100644 index 0000000..001bba6 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java @@ -0,0 +1,5 @@ +package org.mandarin.booking.app.venue; + +public interface HallValidator { + void checkHallExist(Long hallId); +} diff --git a/application/src/main/java/org/mandarin/booking/app/venue/package-info.java b/application/src/main/java/org/mandarin/booking/app/venue/package-info.java new file mode 100644 index 0000000..43a5843 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/venue/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface("venue") +package org.mandarin.booking.app.venue; + +import org.springframework.modulith.NamedInterface; diff --git a/src/main/resources/application-local.yml b/application/src/main/resources/application-local.yml similarity index 94% rename from src/main/resources/application-local.yml rename to application/src/main/resources/application-local.yml index 7586e06..5e4f9a9 100644 --- a/src/main/resources/application-local.yml +++ b/application/src/main/resources/application-local.yml @@ -14,6 +14,7 @@ spring: docker: compose: lifecycle-management: start_only + file: classpath:compose.yaml web.resources.add-mappings: false jwt: token: diff --git a/src/main/resources/application-test.yml b/application/src/main/resources/application-test.yml similarity index 99% rename from src/main/resources/application-test.yml rename to application/src/main/resources/application-test.yml index 71ffcf8..1036f80 100644 --- a/src/main/resources/application-test.yml +++ b/application/src/main/resources/application-test.yml @@ -26,3 +26,4 @@ jwt: #logging: # level: # org.springframework.security: TRACE + diff --git a/src/main/resources/application.yml b/application/src/main/resources/application.yml similarity index 100% rename from src/main/resources/application.yml rename to application/src/main/resources/application.yml diff --git a/compose.yaml b/application/src/main/resources/compose.yaml similarity index 100% rename from compose.yaml rename to application/src/main/resources/compose.yaml diff --git a/application/src/test/java/org/mandarin/booking/BookingApplicationTests.java b/application/src/test/java/org/mandarin/booking/BookingApplicationTests.java new file mode 100644 index 0000000..16bc2fd --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/BookingApplicationTests.java @@ -0,0 +1,20 @@ +package org.mandarin.booking; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BookingApplicationTests { + + @Test + void run() { + try (MockedStatic mocked = Mockito.mockStatic(SpringApplication.class)) { + BookingApplication.main(new String[0]); + mocked.verify(() -> SpringApplication.run(BookingApplication.class, new String[0])); + } + } + +} diff --git a/application/src/test/java/org/mandarin/booking/LoggingAspectTest.java b/application/src/test/java/org/mandarin/booking/LoggingAspectTest.java new file mode 100644 index 0000000..470dfd4 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/LoggingAspectTest.java @@ -0,0 +1,615 @@ +package org.mandarin.booking; + +import static ch.qos.logback.classic.Level.DEBUG; +import static ch.qos.logback.classic.Level.ERROR; +import static ch.qos.logback.classic.Level.INFO; +import static ch.qos.logback.classic.Level.OFF; +import static ch.qos.logback.classic.Level.TRACE; +import static ch.qos.logback.classic.Level.WARN; +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.utils.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 { + + @Autowired + SampleService bean; + @Autowired + BlankMethodOnlyService blankMethodOnlyService; + @Autowired + BlankClassScopeService blankClassScopeService; + @Autowired + WarnClassScopeService warnClassScopeService; + @Autowired + ErrorClassScopeService errorClassScopeService; + private ListAppender la; + + @BeforeEach + void setUp() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(TRACE); + } + + @AfterEach + void tearDown() { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + logger.detachAppender(la); + } + + @Test + @DisplayName("Class-level @Log는 START/END를 scope 레벨에서 제공한다") + void classLevelLogStartEnd() { + SampleService s = bean; + String res = s.doWork(); + assertThat(res).isEqualTo("ok"); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(DEBUG); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doWork"); + assertThat(events.get(1).getLevel()).isEqualTo(DEBUG); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("("); + } + + @Test + @DisplayName("Method-level @Log는 클래스 수준을 우선합니다") + void methodLevelOverrides() { + SampleService s = bean; + String res = s.doTraced(); + assertThat(res).isEqualTo("traced"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.getFirst().getLevel()).isEqualTo(TRACE); + assertThat(events.getFirst().getFormattedMessage()).contains("START").contains("doTraced"); + } + + @Test + @DisplayName("예외적으로 END는 예외 로그로 남는다") + void exceptionLogging() { + SampleService s = bean; + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("IllegalStateException"); + } + + @Test + @DisplayName("WARN scope는 WARN 로그를 출력한다") + void warnLevelMethod() { + SampleService s = bean; + String res = s.doWarn(); + assertThat(res).isEqualTo("warned"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.getFirst().getLevel()).isEqualTo(WARN); + assertThat(events.getFirst().getFormattedMessage()).contains("START").contains("doWarn"); + assertThat(events.get(1).getLevel()).isEqualTo(WARN); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("("); + } + + @Test + @DisplayName("ERROR scope인 경우 START/END가 기제된다") + void errorLevelMethodSuccessful() { + SampleService s = bean; + String res = s.doErrorLevel(); + assertThat(res).isEqualTo("erred"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(ERROR); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doErrorLevel"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("("); + } + + @Test + @DisplayName("알수없는 scope인 경우 기본값인 INFO scope로 로깅된다") + void unknownScopeDefaultsToInfo() { + SampleService s = bean; + String res = s.doCustom(); + assertThat(res).isEqualTo("custom"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.getFirst().getLevel()).isEqualTo(INFO); + } + + @Test + @DisplayName("trim 검증") + void infoScopeCoversExplicitInfoCase() { + SampleService s = bean; + String res = s.doInfo(); + assertThat(res).isEqualTo("info"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(INFO); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doInfo"); + assertThat(events.get(1).getLevel()).isEqualTo(INFO); + assertThat(events.get(1).getFormattedMessage()).contains("END"); + } + + @Test + @DisplayName("기본 scope는 INFO") + void blankMethodScopeFallsBackToInfo() { + ListAppender la = attachAppenderForBlankMethodOnly(); + try { + String res = blankMethodOnlyService.blankOnly(); + assertThat(res).isEqualTo("blank"); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.getFirst().getLevel()).isEqualTo(INFO); + } finally { + detachAppender(la); + } + } + + + @Test + @DisplayName("DEBUG scope일때 로그 레벨이 INFO인 경우 로그가 발생하지 않는다") + void debugScopeButLoggerAtInfoNoLogs() { + SampleService s = bean; + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + Level prev = logger.getLevel(); + try { + logger.setLevel(INFO); + la.list.clear(); + + String res = s.doWork(); + assertThat(res).isEqualTo("ok"); + + assertThat(la.list).isEmpty(); + } finally { + logger.setLevel(prev); + } + } + + @Test + @DisplayName("로거 레벨이 TRACE scope인 경우 INFO 로그가 없다") + void traceScopeButLoggerAtInfoNoLogs() { + SampleService s = bean; + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + Level prev = logger.getLevel(); + try { + logger.setLevel(INFO); + la.list.clear(); + + String res = s.doTraced(); + assertThat(res).isEqualTo("traced"); + + assertThat(la.list).isEmpty(); + } finally { + logger.setLevel(prev); + } + } + + @Test + @DisplayName("예외 로그 레벨이 꺼져 있으면 로그가 기록되지 않는다") + void exceptionLoggingSuppressedWhenErrorDisabled() { + SampleService s = bean; + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + Level prev = logger.getLevel(); + try { + logger.setLevel(OFF); + la.list.clear(); + + String res = s.doErrorLevel(); + + assertThat(res).isEqualTo("erred"); + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + + assertThat(la.list).isEmpty(); + } finally { + logger.setLevel(prev); + } + } + + @Test + @DisplayName("Class-level scope이 OFF면 로그가 없다") + void finallySuccessBranchRunsButNoLogWhenOff() { + SampleService s = bean; + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + Level prev = logger.getLevel(); + try { + logger.setLevel(OFF); + la.list.clear(); + + String res = s.doWork(); + assertThat(res).isEqualTo("ok"); + + assertThat(la.list).isEmpty(); + } finally { + logger.setLevel(prev); + } + } + + + @Test + @DisplayName("Class-level scope가 blank일 때 resolveScope는 INFO로 폴백되어 START/END가 INFO로 찍힌다") + void classBlankScopeFallsBackToInfoOnSuccess() { + ListAppender la = attachAppenderForBlankClass(); + try { + String res = blankClassScopeService.doOk(); + assertThat(res).isEqualTo("ok"); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(INFO); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doOk"); + assertThat(events.get(1).getLevel()).isEqualTo(INFO); + assertThat(events.get(1).getFormattedMessage()).contains("END"); + } finally { + detachAppenderForBlankClass(la); + } + } + + @Test + @DisplayName("proceed에서 예외 발생 시 START(INFO) 후 ERROR 로그가 남는다 (클래스 blank scope)") + void classBlankScopeProceedThrowsLogsError() { + ListAppender la = attachAppenderForBlankClass(); + try { + assertThatThrownBy(() -> blankClassScopeService.fail()) + .isInstanceOf(IllegalStateException.class); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(INFO); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("fail"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("IllegalStateException"); + } finally { + detachAppenderForBlankClass(la); + } + } + + @Test + @DisplayName("Class-level ERROR scope: START/END가 ERROR로 기록된다 (성공 경로)") + void classErrorScopeLogsAtErrorOnSuccess() { + ListAppender la = attachAppenderForErrorClass(); + try { + String res = errorClassScopeService.doOk(); + assertThat(res).isEqualTo("ok"); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(ERROR); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doOk"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END"); + } finally { + detachAppenderForErrorClass(la); + } + } + + @Test + @DisplayName("Class-level ERROR scope: proceed 예외 시 ERROR 로그가 기록된다") + void classErrorScopeProceedThrowsLogsError() { + ListAppender la = attachAppenderForErrorClass(); + try { + assertThatThrownBy(() -> errorClassScopeService.fail()) + .isInstanceOf(IllegalStateException.class); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(ERROR); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("fail"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("IllegalStateException"); + } finally { + detachAppenderForErrorClass(la); + } + } + + @Test + @DisplayName("Class-level WARN scope: START/END가 WARN으로 기록된다") + void classWarnScopeLogsAtWarnOnSuccess() { + ListAppender la = attachAppenderForWarnClass(); + try { + String res = warnClassScopeService.doOk(); + assertThat(res).isEqualTo("ok"); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getLevel()).isEqualTo(WARN); + assertThat(events.get(0).getFormattedMessage()).contains("START").contains("doOk"); + assertThat(events.get(1).getLevel()).isEqualTo(WARN); + assertThat(events.get(1).getFormattedMessage()).contains("END"); + } finally { + detachAppenderForWarnClass(la); + } + } + + @Test + @DisplayName("logAtLevel: WARN/ERROR 레벨 라우팅이 직접 검증된다") + void logAtLevelWarnAndErrorAreVerifiedDirectly() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger( + SampleLoggedService.class); + logger.setLevel(TRACE); + la.list.clear(); + var aspect = new LoggingAspect(); + + var m = LoggingAspect.class.getDeclaredMethod("logAtLevel", org.slf4j.Logger.class, String.class, String.class, + Object[].class); + m.setAccessible(true); + m.invoke(aspect, logger, "WARN", "warn {}", new Object[]{"x"}); + + assertThat(la.list).hasSize(1); + assertThat(la.list.getFirst().getLevel()).isEqualTo(WARN); + assertThat(la.list.getFirst().getFormattedMessage()).contains("warn x"); + + la.list.clear(); + m.invoke(aspect, logger, "ERROR", "error {}", new Object[]{"y"}); + + assertThat(la.list).hasSize(1); + assertThat(la.list.getFirst().getLevel()).isEqualTo(ERROR); + assertThat(la.list.getFirst().getFormattedMessage()).contains("error y"); + } + + @Test + @DisplayName("logAtLevel: WARN/INFO 비활성화 시 로그가 발생하지 않는다") + void logAtLevelNoWarnOrInfoWhenDisabled() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(SampleLoggedService.class); + la.list.clear(); + var aspect = new LoggingAspect(); + var m = LoggingAspect.class.getDeclaredMethod("logAtLevel", + org.slf4j.Logger.class, + String.class, + String.class, + Object[].class); + m.setAccessible(true); + + logger.setLevel(ERROR); + m.invoke(aspect, logger, "WARN", "warn {}", new Object[]{"x"}); + assertThat(la.list).isEmpty(); + + logger.setLevel(WARN); + m.invoke(aspect, logger, "INFO", "info {}", new Object[]{"y"}); + assertThat(la.list).isEmpty(); + } + + @Test + @DisplayName("finally의 if(success)==false 분기가 명시적으로 검증된다 (END 로그 없음)") + void finallyIfSuccessFalseBranchExplicit() { + SampleService s = bean; + la.list.clear(); + + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getFormattedMessage()).contains("START"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("IllegalStateException"); + } + + @Test + @DisplayName("정상 반환 시 finally의 if(success)==true 분기가 명시적으로 검증된다") + void catchJoinPointExceptionReturnErrorMessageWithFailStatus() { + SampleService s = bean; + la.list.clear(); + + assertThatThrownBy(s::fail).isInstanceOf(IllegalStateException.class); + List events = la.list; + assertThat(events).hasSize(2); + assertThat(events.get(0).getFormattedMessage()).contains("START"); + assertThat(events.get(1).getLevel()).isEqualTo(ERROR); + assertThat(events.get(1).getFormattedMessage()).contains("END").contains("IllegalStateException"); + } + + private ListAppender attachAppenderForBlankClass() { + Logger logger = (Logger) LoggerFactory.getLogger(BlankClassScopeServiceImpl.class); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(TRACE); + return la; + } + + private ListAppender attachAppenderForBlankMethodOnly() { + Logger logger = (Logger) LoggerFactory.getLogger(MethodBlankOnlyService.class); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(TRACE); + return la; + } + + private static void detachAppender(ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(MethodBlankOnlyService.class); + logger.detachAppender(la); + } + + private static void detachAppenderForBlankClass(ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(BlankClassScopeServiceImpl.class); + logger.detachAppender(la); + } + + private static ListAppender attachAppenderForWarnClass() { + Logger logger = (Logger) LoggerFactory.getLogger(WarnClassScopeServiceImpl.class); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(WARN); + return la; + } + + private static void detachAppenderForWarnClass(ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(WarnClassScopeServiceImpl.class); + logger.detachAppender(la); + } + + private static ListAppender attachAppenderForErrorClass() { + Logger logger = (Logger) LoggerFactory.getLogger(ErrorClassScopeServiceImpl.class); + ListAppender la = new ListAppender<>(); + la.start(); + logger.addAppender(la); + logger.setLevel(ERROR); + return la; + } + + private static void detachAppenderForErrorClass(ListAppender la) { + Logger logger = (Logger) LoggerFactory.getLogger(ErrorClassScopeServiceImpl.class); + logger.detachAppender(la); + } + + interface SampleService { + String doWork(); + + String doTraced(); + + void fail(); + + String doWarn(); + + String doErrorLevel(); + + String doCustom(); + + String doInfo(); + } + + interface BlankMethodOnlyService { + String blankOnly(); + } + + interface BlankClassScopeService { + String doOk(); + + void fail(); + + } + + interface WarnClassScopeService { + String doOk(); + + } + + interface ErrorClassScopeService { + String doOk(); + + void fail(); + + } + + @Configuration + @Import({AopAutoConfiguration.class, LoggingAspect.class}) + static class TestConfig { + @Bean + SampleService sampleLoggedService() { + return new SampleLoggedService(); + } + + @Bean + BlankMethodOnlyService blankMethodOnlyService() { + return new MethodBlankOnlyService(); + } + + @Bean + BlankClassScopeService blankClassScopeService() { + return new BlankClassScopeServiceImpl(); + } + + @Bean + WarnClassScopeService warnClassScopeService() { + return new WarnClassScopeServiceImpl(); + } + + @Bean + ErrorClassScopeService errorClassScopeService() { + return new ErrorClassScopeServiceImpl(); + } + } + + static class MethodBlankOnlyService implements BlankMethodOnlyService { + @Log(scope = " ") + public String blankOnly() { + return "blank"; + } + } + + @Log(scope = " ") + static class BlankClassScopeServiceImpl implements BlankClassScopeService { + public String doOk() { + return "ok"; + } + + public void fail() { + throw new IllegalStateException("boom"); + } + } + + @Log(scope = "WARN") + static class WarnClassScopeServiceImpl implements WarnClassScopeService { + public String doOk() { + return "ok"; + } + + } + + @Log(scope = "ERROR") + static class ErrorClassScopeServiceImpl implements ErrorClassScopeService { + public String doOk() { + return "ok"; + } + + public void fail() { + throw new IllegalStateException("boom"); + } + + } + + @Log(scope = "DEBUG") + static class SampleLoggedService implements SampleService { + public String doWork() { + return "ok"; + } + + @Log(scope = "TRACE") + public String doTraced() { + return "traced"; + } + + public void 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"; + } + + @Log(scope = " info ") + public String doInfo() { + return "info"; + } + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java similarity index 87% rename from src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java rename to application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java index 4f49712..4503884 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/JwtFilterTest.java +++ b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java @@ -1,18 +1,19 @@ 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 static org.mandarin.booking.adapter.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.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.NoRestDocs; -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.mandarin.booking.adapter.JwtFilter; +import org.mandarin.booking.adapter.TokenUtils; +import org.mandarin.booking.adapter.security.AuthIntegrationTest.TestAuthController; +import org.mandarin.booking.adapter.security.AuthIntegrationTest.TestAuthController.TestSecurityConfig; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.NoRestDocs; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -34,7 +35,7 @@ @IntegrationTest @NoRestDocs @Import({TestSecurityConfig.class, TestAuthController.class}) -class JwtFilterTest { +class AuthIntegrationTest { 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"; @@ -74,7 +75,7 @@ void failToAuth(@Autowired IntegrationTestUtils testUtils) { .withAuthorization(invalidToken) .assertFailure(); assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + assertThat(response.getData()).contains("유효한 토큰이 없습니다."); } @Test @@ -89,7 +90,7 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + assertThat(response.getData()).contains("유효한 토큰이 없습니다."); } @Test @@ -122,7 +123,7 @@ void blankTokenWillFailToAuth( // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("토큰이 비어있습니다."); + assertThat(response.getData()).contains("토큰이 비어있습니다."); } @RestController diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java similarity index 79% rename from src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java rename to application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java index 530bc62..de39963 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java +++ b/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java @@ -1,10 +1,12 @@ package org.mandarin.booking.adapter.security; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; -import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.adapter.CustomMemberAuthenticationToken; +import org.mandarin.booking.utils.IntegrationTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,7 +22,7 @@ void supports() { } @Test - void supportsFailure(){ + void supportsFailure() { var isSupported = provider.supports(String.class); assertThat(isSupported).isFalse(); } diff --git a/application/src/test/java/org/mandarin/booking/arch/BaseArchitectureTest.java b/application/src/test/java/org/mandarin/booking/arch/BaseArchitectureTest.java new file mode 100644 index 0000000..b7d429e --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/arch/BaseArchitectureTest.java @@ -0,0 +1,18 @@ +package org.mandarin.booking.arch; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.BeforeAll; + +class BaseArchitectureTest { + protected static final String BASE = "org.mandarin.booking"; + protected static JavaClasses classes; + + @BeforeAll + static void importClasses() { + classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE); + } +} diff --git a/application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java b/application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java new file mode 100644 index 0000000..f666bde --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java @@ -0,0 +1,77 @@ +package org.mandarin.booking.arch; + + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.APPLICATION; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.COMMON; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.DOMAIN; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.EXTERNAL; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.INTERNAL; +import static org.mandarin.booking.arch.ModuleDependencyRulesTest.Module.packages; + +import org.junit.jupiter.api.Test; + +class ModuleDependencyRulesTest extends BaseArchitectureTest { + + @Test + void commonModuleShouldPure() { + noClasses().that().resideInAnyPackage(BASE + ".common..") + .should().dependOnClassesThat().resideInAnyPackage( + BASE + ".application..", + BASE + ".domain..", + BASE + ".internal..", + BASE + ".external.." + ) + .allowEmptyShould(true) + .check(classes); + } + + @Test + void onlyApplicationMayDependOnDomain() { + noClasses() + .that().resideInAnyPackage( + packages(COMMON, INTERNAL, EXTERNAL) + ) + .should().dependOnClassesThat().resideInAnyPackage(packages(APPLICATION, DOMAIN)) + .because("도메인 영역은 오직 application 계층에서만 의존 가능해야 한다") + .allowEmptyShould(true) + .check(classes); + } + + @Test + void domainShouldDependOnlyOnCommonAmongProjectModules() { + noClasses() + .that().resideInAnyPackage(packages(DOMAIN)) + .should().dependOnClassesThat() + .resideInAnyPackage( + packages(APPLICATION, + INTERNAL, + EXTERNAL) + ) + .because("domain은 프로젝트 내부 모듈 중 common만 의존할 수 있다") + .allowEmptyShould(true) + .check(classes); + } + + enum Module { + APPLICATION, + COMMON, + INTERNAL, + EXTERNAL, + DOMAIN; + + @Override + public String toString() { + return BASE + "." + name().toLowerCase(); + } + + static String[] packages(Module... modules) { + String[] packages = new String[modules.length]; + for (int i = 0; i < modules.length; i++) { + packages[i] = modules[i].toString() + ".."; + } + return packages; + } + + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java new file mode 100644 index 0000000..03f274c --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java @@ -0,0 +1,187 @@ +package org.mandarin.booking.utils; + +import static io.restassured.RestAssured.given; +import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; +import static java.lang.StackWalker.getInstance; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.filter.Filter; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import java.lang.StackWalker.StackFrame; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.springframework.core.env.Environment; +import org.springframework.restdocs.ManualRestDocumentation; +import org.springframework.restdocs.restassured.RestAssuredRestDocumentation; +import org.springframework.stereotype.Component; + +@Component +public record DocsUtils(Environment environment, + ObjectMapper objectMapper) { + + private static final ManualRestDocumentation restDocumentation = new ManualRestDocumentation(); + private static final String DISPLAY_SLASH = "/"; + + private static volatile boolean started = false; + + public String execute(String method, String path, Object requestBody, Map headers) + throws Exception { + var baseSnippet = sanitize(method, path, false); + var methodSpecificSnippet = sanitize(method, path, true); + + boolean disableDocs = isRestDocsDisabledForCurrentCall(); + if (!disableDocs) { + ensureStarted(); + } + var spec = prepareSpec(headers, disableDocs); + + if ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method)) { + spec.contentType(ContentType.JSON); + if (requestBody != null) { + spec.body(objectMapper.writeValueAsString(requestBody)); + } + } + + if (!disableDocs) { + spec = spec.filter(docFilter(baseSnippet)).filter(docFilter(methodSpecificSnippet)); + } + + var resp = switch (method.toUpperCase()) { + case "GET" -> spec.when().get(path); + case "POST" -> spec.when().post(path); + case "PUT" -> spec.when().put(path); + case "PATCH" -> spec.when().patch(path); + case "DELETE" -> spec.when().delete(path); + default -> throw new IllegalArgumentException("Unsupported method: " + method); + }; + return resp.then().extract().asString(); + } + + private String sanitize(String method, String path, boolean withMethodSuffix) { + String groupTitle = getCurrentTestClass() + .flatMap(this::getDisplayNameOfClass) + .orElseGet(() -> (method.toUpperCase() + " " + path).replace("/", DISPLAY_SLASH).trim()); + + String name = groupTitle.replace("/", DISPLAY_SLASH).replaceAll("\\s+", " ").trim(); + + if (withMethodSuffix) { + String methodTitle = getCurrentTestMethodName() + .flatMap(mn -> getCurrentTestClass().flatMap(cls -> getDisplayNameOfMethod(cls, mn)) + .or(() -> Optional.of(mn))) + .orElse(""); + + if (!methodTitle.isEmpty()) { + name = name + " - " + methodTitle; + } + } + return name; + } + + private Optional> getCurrentTestClass() { + try { + return getInstance(RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .map(StackFrame::getDeclaringClass) + .filter(cls -> cls.getName().startsWith("org.mandarin")) + .filter(cls -> cls.isAnnotationPresent(IntegrationTest.class)) + .findFirst()); + } catch (Throwable t) { + return Optional.empty(); + } + } + + private Optional getDisplayNameOfClass(Class cls) { + DisplayName ann = cls.getAnnotation(DisplayName.class); + return Optional.ofNullable(ann).map(DisplayName::value); + } + + private Optional getDisplayNameOfMethod(Class cls, String methodName) { + try { + Method m = Arrays.stream(cls.getDeclaredMethods()) + .filter(mm -> mm.getName().equals(methodName)) + .findFirst() + .orElse(null); + if (m == null) { + return Optional.empty(); + } + DisplayName ann = m.getAnnotation(DisplayName.class); + return Optional.ofNullable(ann).map(DisplayName::value); + } catch (Throwable t) { + return Optional.empty(); + } + } + + private Optional getCurrentTestMethodName() { + try { + return getInstance(RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .filter(f -> f.getDeclaringClass().getName().startsWith("org.mandarin")) + .filter(f -> f.getDeclaringClass().isAnnotationPresent(IntegrationTest.class)) + .findFirst() + .map(StackFrame::getMethodName)); + } catch (Throwable t) { + return Optional.empty(); + } + } + + private boolean isRestDocsDisabledForCurrentCall() { + try { + return getInstance(RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .map(StackFrame::getDeclaringClass) + .filter(cls -> cls.getName().startsWith("org.mandarin")) + .anyMatch(cls -> cls.isAnnotationPresent(NoRestDocs.class))); + } catch (Throwable t) { + return false; + } + } + + private void ensureStarted() { + if (!started) { + synchronized (DocsUtils.class) { + if (!started) { + restDocumentation.beforeTest(DocsUtils.class, "integration-tests"); + started = true; + } + } + } + } + + private RequestSpecification withDocs(RequestSpecification spec) { + return spec.filter(RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)); + } + + private Filter docFilter(String snippet) { + return document( + snippet, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ); + } + + private RequestSpecification prepareSpec(Map headers, boolean disableDocs) { + Integer port = environment.getProperty("local.server.port", Integer.class); + if (port == null) { + String p = environment.getProperty("local.server.port"); + port = (p != null) ? Integer.parseInt(p) : 0; + } + var spec = given() + .port(port) + .accept(ContentType.JSON); + if (!disableDocs) { + spec = withDocs(spec); + } + if (headers != null) { + headers.forEach(spec::header); + } + return spec; + } +} diff --git a/src/test/java/org/mandarin/booking/IntegrationTest.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTest.java similarity index 87% rename from src/test/java/org/mandarin/booking/IntegrationTest.java rename to application/src/test/java/org/mandarin/booking/utils/IntegrationTest.java index 0343906..014ca21 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTest.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTest.java @@ -1,9 +1,10 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import org.mandarin.booking.BookingApplication; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java similarity index 83% rename from src/test/java/org/mandarin/booking/IntegrationTestUtils.java rename to application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index fd844b0..859b7f6 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -1,9 +1,9 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; -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 static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; +import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -11,20 +11,19 @@ import java.util.Collection; import java.util.List; import java.util.UUID; -import org.mandarin.booking.app.TokenUtils; -import org.mandarin.booking.app.persist.HallCommandRepository; -import org.mandarin.booking.app.persist.MemberCommandRepository; -import org.mandarin.booking.app.persist.ShowCommandRepository; +import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.TokenHolder; +import org.mandarin.booking.adapter.TokenUtils; +import org.mandarin.booking.app.member.MemberCommandRepository; +import org.mandarin.booking.app.show.ShowCommandRepository; +import org.mandarin.booking.app.venue.HallCommandRepository; 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.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.venue.Hall; -import org.springframework.security.core.GrantedAuthority; import org.springframework.test.util.ReflectionTestUtils; public record IntegrationTestUtils(MemberCommandRepository memberRepository, @@ -68,7 +67,7 @@ public String getAuthToken(Member member) { } public TokenHolder getUserToken(String userId, String nickname, - Collection authorities) { + Collection authorities) { return tokenUtils.generateToken(userId, nickname, authorities); } diff --git a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java similarity index 94% rename from src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java rename to application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java index e5d9472..5eff281 100644 --- a/src/test/java/org/mandarin/booking/IntegrationTestUtilsSpecs.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java @@ -1,7 +1,7 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import com.fasterxml.jackson.core.type.TypeReference; import java.util.HashMap; @@ -28,7 +28,8 @@ void post_echo_success( // Act var response = integrationUtils.post("/test/echo", payload) - .assertSuccess(new TypeReference>() {}); + .assertSuccess(new TypeReference>() { + }); // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); diff --git a/src/test/java/org/mandarin/booking/JwtTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/JwtTestUtils.java similarity index 97% rename from src/test/java/org/mandarin/booking/JwtTestUtils.java rename to application/src/test/java/org/mandarin/booking/utils/JwtTestUtils.java index 4d3308c..9373f5d 100644 --- a/src/test/java/org/mandarin/booking/JwtTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/JwtTestUtils.java @@ -1,4 +1,4 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import static java.util.Base64.getUrlDecoder; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/org/mandarin/booking/fixture/MemberFixture.java b/application/src/test/java/org/mandarin/booking/utils/MemberFixture.java similarity index 95% rename from src/test/java/org/mandarin/booking/fixture/MemberFixture.java rename to application/src/test/java/org/mandarin/booking/utils/MemberFixture.java index deb6724..bf48ac2 100644 --- a/src/test/java/org/mandarin/booking/fixture/MemberFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/MemberFixture.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.fixture; +package org.mandarin.booking.utils; import java.util.UUID; diff --git a/src/test/java/org/mandarin/booking/NoRestDocs.java b/application/src/test/java/org/mandarin/booking/utils/NoRestDocs.java similarity index 81% rename from src/test/java/org/mandarin/booking/NoRestDocs.java rename to application/src/test/java/org/mandarin/booking/utils/NoRestDocs.java index 17a56cb..e9a3f25 100644 --- a/src/test/java/org/mandarin/booking/NoRestDocs.java +++ b/application/src/test/java/org/mandarin/booking/utils/NoRestDocs.java @@ -1,4 +1,4 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/src/test/java/org/mandarin/booking/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java similarity index 81% rename from src/test/java/org/mandarin/booking/TestConfig.java rename to application/src/test/java/org/mandarin/booking/utils/TestConfig.java index 2cf1f84..0cbd7ad 100644 --- a/src/test/java/org/mandarin/booking/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -1,10 +1,10 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import com.fasterxml.jackson.databind.ObjectMapper; -import org.mandarin.booking.app.TokenUtils; -import org.mandarin.booking.app.persist.HallCommandRepository; -import org.mandarin.booking.app.persist.MemberCommandRepository; -import org.mandarin.booking.app.persist.ShowCommandRepository; +import org.mandarin.booking.adapter.TokenUtils; +import org.mandarin.booking.app.member.MemberCommandRepository; +import org.mandarin.booking.app.show.ShowCommandRepository; +import org.mandarin.booking.app.venue.HallCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; diff --git a/src/test/java/org/mandarin/booking/TestResult.java b/application/src/test/java/org/mandarin/booking/utils/TestResult.java similarity index 89% rename from src/test/java/org/mandarin/booking/TestResult.java rename to application/src/test/java/org/mandarin/booking/utils/TestResult.java index 4bdc415..d4068f8 100644 --- a/src/test/java/org/mandarin/booking/TestResult.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestResult.java @@ -1,4 +1,4 @@ -package org.mandarin.booking; +package org.mandarin.booking.utils; import static org.assertj.core.api.Assertions.fail; @@ -7,25 +7,24 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.Map; -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.jspecify.annotations.NonNull; +import org.mandarin.booking.adapter.ApiResponse; +import org.mandarin.booking.adapter.ApiStatus; +import org.mandarin.booking.adapter.ErrorResponse; +import org.mandarin.booking.adapter.SuccessResponse; public class TestResult { - private Executor executor; - private final String path; private final Object request; private final Map headers = new HashMap<>(); + private Executor executor; + private ObjectMapper objectMapper; public TestResult(String path, Object request) { this.path = path; this.request = request; } - private ObjectMapper objectMapper; - public ApiResponse assertSuccess(Class responseType) { var response = readSuccessResponse( getResponse(), @@ -59,19 +58,15 @@ public ErrorResponse assertFailure() { var response = readErrorResponse(); if (response == null) { throw new AssertionError("Expected Error response, but got: " + null); - }else if (response.getStatus() == ApiStatus.SUCCESS) { + } else if (response.getStatus() == ApiStatus.SUCCESS) { throw new AssertionError("Expected Error response, but got SUCCESS: " + response); } return response; } - public TestResult withHeader(String headerName, String headerValue) { - headers.put(headerName, headerValue); - return this; - } public TestResult withAuthorization(String token) { - this.withHeader("Authorization", token); + headers.put("Authorization", token); return this; } @@ -122,7 +117,8 @@ private ApiResponse readSuccessResponse(String raw, Class dataType) { 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); + fail("Failed to parse SuccessResponse with data type " + dataType.getName() + ": " + + primary.getMessage(), primary); return null; } } @@ -141,7 +137,7 @@ private ErrorResponse readErrorResponse() { } } - private SuccessResponse readSuccessResponse(String raw, TypeReference typeRef) { + private SuccessResponse<@NonNull T> readSuccessResponse(String raw, TypeReference typeRef) { try { var inner = objectMapper.getTypeFactory().constructType(typeRef); var wrapper = objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, inner); diff --git a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java similarity index 91% rename from src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java rename to application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index 519eed0..8fe2193 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -1,14 +1,14 @@ package org.mandarin.booking.webapi.auth.login; 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.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.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.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.utils.JwtTestUtils.assertJwtFormat; +import static org.mandarin.booking.utils.JwtTestUtils.getExpiration; +import static org.mandarin.booking.utils.JwtTestUtils.getTokenClaims; +import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import io.jsonwebtoken.security.Keys; import java.util.Date; @@ -17,11 +17,11 @@ 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.app.persist.MemberQueryRepository; +import org.mandarin.booking.TokenHolder; +import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.AuthRequest; -import org.mandarin.booking.domain.member.TokenHolder; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -239,6 +239,7 @@ public class POST_specs { assertThat(savedMember).isNotNull(); } + private static AuthRequest[] blankUserIdRequests() { return new AuthRequest[]{ new AuthRequest(null, generatePassword()), diff --git a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java similarity index 85% rename from src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java rename to application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index 15894a2..b883a18 100644 --- a/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -1,15 +1,15 @@ package org.mandarin.booking.webapi.auth.reissue; 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.MemberAuthority.USER; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.utils.JwtTestUtils.assertJwtFormat; +import static org.mandarin.booking.utils.JwtTestUtils.getExpiration; +import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import io.jsonwebtoken.security.Keys; import java.util.Date; @@ -18,11 +18,11 @@ 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.TokenUtils; +import org.mandarin.booking.TokenHolder; +import org.mandarin.booking.adapter.TokenUtils; import org.mandarin.booking.domain.member.ReissueRequest; -import org.mandarin.booking.domain.member.TokenHolder; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; @@ -120,29 +120,51 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } - + @Test void 요청_body가_누락된_경우_400_Bad_Request가_발생한다( @Autowired IntegrationTestUtils testUtils ) { // Arrange var request = new ReissueRequest(null); - + // Act var response = testUtils.post( "/api/auth/reissue", request ) .assertFailure(); - + // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + @Test + void 존재하지_않는_사용자의_refresh_token을_요청하면_401_Unauthorize가_발생한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TokenUtils tokenUtils + ) { + // Arrange + var validRefreshToken = tokenUtils.generateToken(generateUserId(), generateNickName(), List.of(USER)) + .refreshToken(); + var request = new ReissueRequest(validRefreshToken); + + // user 생성 안함 + + // Act + var response = testUtils.post( + "/api/auth/reissue", + request + ) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + } @Nested @TestPropertySource(properties = "jwt.token.refresh=100") - class ReissueShortToken{ + class ReissueShortToken { @Test void 만료된_refresh_token으로_요청하면_401_Unauthorize가_발생한다( @Autowired IntegrationTestUtils testUtils @@ -163,26 +185,4 @@ class ReissueShortToken{ assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); } } - - @Test - void 존재하지_않는_사용자의_refresh_token을_요청하면_401_Unauthorize가_발생한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TokenUtils tokenUtils - ) { - // Arrange - var validRefreshToken = tokenUtils.generateToken(generateUserId(), generateNickName(), List.of(USER)).refreshToken(); - var request = new ReissueRequest(validRefreshToken); - - // user 생성 안함 - - // Act - var response = testUtils.post( - "/api/auth/reissue", - request - ) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - } } diff --git a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java similarity index 94% rename from src/test/java/org/mandarin/booking/webapi/member/POST_specs.java rename to application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 586e3ba..3ba2b08 100644 --- a/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -1,21 +1,21 @@ package org.mandarin.booking.webapi.member; import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.fixture.MemberFixture.EmailGenerator.generateEmail; -import static org.mandarin.booking.fixture.MemberFixture.UserIdGenerator.generateUserId; +import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mandarin.booking.IntegrationTest; -import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.app.persist.MemberQueryRepository; +import org.mandarin.booking.app.member.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; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.MemberFixture.NicknameGenerator; +import org.mandarin.booking.utils.MemberFixture.PasswordGenerator; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest diff --git a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java b/application/src/test/java/org/mandarin/booking/webapi/not_found/GET_specs.java similarity index 60% rename from src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java rename to application/src/test/java/org/mandarin/booking/webapi/not_found/GET_specs.java index 33b6069..45f344d 100644 --- a/src/test/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandlerTest.java +++ b/application/src/test/java/org/mandarin/booking/webapi/not_found/GET_specs.java @@ -1,20 +1,19 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.webapi.not_found; import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import org.junit.jupiter.api.Test; -import org.mandarin.booking.IntegrationTest; -import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.NoRestDocs; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.NoRestDocs; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @NoRestDocs -class GlobalExceptionHandlerTest { - +public class GET_specs { @Test - void endpointNotFound(@Autowired IntegrationTestUtils testUtils){ + void endpointNotFound(@Autowired IntegrationTestUtils testUtils) { // Act var request = testUtils.get("/not-found") .assertFailure(); diff --git a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java similarity index 94% rename from src/test/java/org/mandarin/booking/webapi/show/POST_specs.java rename to application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 3a0f685..5159b3b 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -1,11 +1,11 @@ package org.mandarin.booking.webapi.show; 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.INTERNAL_SERVER_ERROR; -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.ADMIN; +import static org.mandarin.booking.MemberAuthority.ADMIN; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import java.time.LocalDate; import java.util.List; @@ -14,16 +14,35 @@ 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.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @DisplayName("POST /api/show") public class POST_specs { + static List nullOrBlankElementRequests() { + return List.of( + new ShowRegisterRequest("", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), null) + ); + } + @Test void 올바른_요청을_보내면_status가_SUCCESS이다( @Autowired IntegrationTestUtils testUtils @@ -161,6 +180,7 @@ public class POST_specs { } + @SuppressWarnings("NonAsciiCharacters") @Test void 중복된_제목의_공연을_등록하면_INTERNAL_SERVER_ERROR가_발생한다( @Autowired IntegrationTestUtils testUtils @@ -190,25 +210,6 @@ public class POST_specs { assertThat(response.getData()).contains("이미 존재하는 공연 이름입니다:"); } - static List nullOrBlankElementRequests() { - return List.of( - new ShowRegisterRequest("", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), - LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, - LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), null) - ); - } - private ShowRegisterRequest validShowRegisterRequest() { return new ShowRegisterRequest( UUID.randomUUID().toString().substring(0, 10), diff --git a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java similarity index 89% rename from src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java rename to application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 97bb1c3..580f485 100644 --- a/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -1,24 +1,25 @@ package org.mandarin.booking.webapi.show.schedule; +import static java.util.Objects.requireNonNull; 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.FORBIDDEN; -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.SUCCESS; -import static org.mandarin.booking.domain.member.MemberAuthority.ADMIN; -import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; -import static org.mandarin.booking.domain.member.MemberAuthority.USER; +import static org.mandarin.booking.MemberAuthority.ADMIN; +import static org.mandarin.booking.MemberAuthority.DISTRIBUTOR; +import static org.mandarin.booking.MemberAuthority.USER; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import java.time.LocalDate; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mandarin.booking.IntegrationTest; -import org.mandarin.booking.IntegrationTestUtils; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -33,7 +34,7 @@ public class POST_specs { var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var hall = testUtils.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, hall.getId(), + show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -54,7 +55,7 @@ public class POST_specs { var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var hall = testUtils.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, hall.getId(), + show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -75,7 +76,7 @@ public class POST_specs { var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var hall = testUtils.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, hall.getId(), + show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -132,7 +133,7 @@ public class POST_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId 10L, @@ -157,7 +158,7 @@ public class POST_specs { // Arrange var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( - show.getId(), + requireNonNull(show.getId()), 9999L,// 존재하지 않는 hallId LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30) @@ -181,8 +182,8 @@ public class POST_specs { var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); var hall = testUtils.insertDummyHall(); var request = new ShowScheduleRegisterRequest( - show.getId(), - hall.getId(), + requireNonNull(show.getId()), + requireNonNull(hall.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), LocalDateTime.of(2023, 9, 10, 21, 30) ); @@ -208,7 +209,7 @@ public class POST_specs { LocalDate.now().minusDays(1), LocalDate.now().plusDays(10) ); - var request = generateShowScheduleRegisterRequest(show, hall.getId(), + var request = generateShowScheduleRegisterRequest(show, requireNonNull(hall.getId()), LocalDateTime.now(), LocalDateTime.now().plusHours(2) ); @@ -259,7 +260,7 @@ private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show sho LocalDateTime startAt, LocalDateTime endAt, long hallId) { return new ShowScheduleRegisterRequest( - show.getId(), + requireNonNull(show.getId()), hallId, startAt, endAt diff --git a/build.gradle b/build.gradle index f51f311..8ae3705 100644 --- a/build.gradle +++ b/build.gradle @@ -1,113 +1,67 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.5.4' - id 'io.spring.dependency-management' version '1.1.7' - id 'com.epages.restdocs-api-spec' version '0.18.2' - id 'org.asciidoctor.jvm.convert' version '3.3.2' -} - -group = 'org.mandarin' -version = '0.0.1-SNAPSHOT' - -ext { - snippetsDir = file('build/generated-snippets') -} +buildscript { + ext { + dependencyManagementVersion = '1.1.7' + springBootVersion = '3.5.4' + } -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} + repositories { + mavenCentral() + } -repositories { - mavenCentral() + dependencies { + classpath("io.spring.gradle:dependency-management-plugin:${dependencyManagementVersion}") + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") + } } -configurations { - byteBuddyAgent - asciidoctorExt +plugins { + id 'base' } -dependencies { - // ---- 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' - // ---- Querydsl ---- - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' - // Ensure APT has Jakarta APIs (some environments require explicit presence) - annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' +subprojects { + apply plugin: 'java-library' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' - // ---- 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' + group = 'org.yechan' + version = '1.0-SNAPSHOT' + sourceCompatibility = '21' - // ---- Lombok ---- - compileOnly 'org.projectlombok:lombok:1.18.36' - annotationProcessor 'org.projectlombok:lombok:1.18.36' - testCompileOnly 'org.projectlombok:lombok:1.18.36' - testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' - - // ---- Dev Only ---- - developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - - // ---- 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' - - // ---- API Docs (REST Docs + Rest Assured) ---- - testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + configurations { + developmentOnly + runtimeClasspath { + extendsFrom developmentOnly + } + compileOnly { + extendsFrom annotationProcessor + } + } - asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.1.0' + repositories { + mavenCentral() + } - // null safety - implementation 'org.jspecify:jspecify:1.0.0' -} -ext { - set('snippetsDir', file('build/generated-snippets')) -} + dependencies { + // ---- Lombok ---- + compileOnly 'org.projectlombok:lombok:1.18.36' + annotationProcessor 'org.projectlombok:lombok:1.18.36' + testCompileOnly 'org.projectlombok:lombok:1.18.36' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' -tasks.named('test') { - useJUnitPlatform() - systemProperty 'spring.profiles.active', 'test' - // javaagent 선부착 (Mockito self-attach 방지) - jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}" - outputs.dir snippetsDir -} + // ---- Testing ---- + testImplementation 'org.mockito:mockito-inline:5.2.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -asciidoctor { - group = 'documentation' - description = 'Converts AsciiDoc files to HTML, including REST Docs snippets.' - dependsOn test - baseDirFollowsSourceDir() - sourceDir = file('src/docs') - sources { - include '**/*.adoc' + // null safety + implementation 'org.jspecify:jspecify:1.0.0' } - inputs.dir snippetsDir - attributes 'snippets': snippetsDir - resources { - from(snippetsDir) { into 'snippets' } + + tasks.named('test') { + useJUnitPlatform() +// failFast = true + jvmArgs = ['-XX:+EnableDynamicAgentLoading', '-Xshare:off'] + systemProperty 'spring.profiles.active', 'test' } - outputDir = layout.buildDirectory.dir("docs/asciidoc").get().asFile } - diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..6ef52a1 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,9 @@ +bootJar { + enabled = false +} +jar { + enabled = true +} + +dependencies { +} diff --git a/src/main/java/org/mandarin/booking/domain/member/AuthException.java b/common/src/main/java/org/mandarin/booking/AuthException.java similarity index 57% rename from src/main/java/org/mandarin/booking/domain/member/AuthException.java rename to common/src/main/java/org/mandarin/booking/AuthException.java index c037795..1c53b7e 100644 --- a/src/main/java/org/mandarin/booking/domain/member/AuthException.java +++ b/common/src/main/java/org/mandarin/booking/AuthException.java @@ -1,6 +1,4 @@ -package org.mandarin.booking.domain.member; - -import org.mandarin.booking.domain.DomainException; +package org.mandarin.booking; public class AuthException extends DomainException { public AuthException(String message) { diff --git a/src/main/java/org/mandarin/booking/domain/DomainException.java b/common/src/main/java/org/mandarin/booking/DomainException.java similarity index 90% rename from src/main/java/org/mandarin/booking/domain/DomainException.java rename to common/src/main/java/org/mandarin/booking/DomainException.java index 41d7a26..ea4a461 100644 --- a/src/main/java/org/mandarin/booking/domain/DomainException.java +++ b/common/src/main/java/org/mandarin/booking/DomainException.java @@ -1,10 +1,11 @@ -package org.mandarin.booking.domain; +package org.mandarin.booking; import lombok.Getter; @Getter public class DomainException extends RuntimeException { private String status = "INTERNAL_SERVER_ERROR"; + public DomainException(String message) { super(message); } diff --git a/common/src/main/java/org/mandarin/booking/MemberAuthority.java b/common/src/main/java/org/mandarin/booking/MemberAuthority.java new file mode 100644 index 0000000..cdbc1b8 --- /dev/null +++ b/common/src/main/java/org/mandarin/booking/MemberAuthority.java @@ -0,0 +1,11 @@ +package org.mandarin.booking; + +public enum MemberAuthority { + USER, + DISTRIBUTOR, + ADMIN; + + public String getAuthority() { + return "ROLE_" + name().toUpperCase(); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java b/common/src/main/java/org/mandarin/booking/TokenHolder.java similarity index 62% rename from src/main/java/org/mandarin/booking/domain/member/TokenHolder.java rename to common/src/main/java/org/mandarin/booking/TokenHolder.java index f91c42a..8be80fa 100644 --- a/src/main/java/org/mandarin/booking/domain/member/TokenHolder.java +++ b/common/src/main/java/org/mandarin/booking/TokenHolder.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.domain.member; +package org.mandarin.booking; public record TokenHolder(String accessToken, String refreshToken) { } diff --git a/docs/devlog/250809.md b/docs/devlog/250809.md index 2476bca..4e6d9e3 100644 --- a/docs/devlog/250809.md +++ b/docs/devlog/250809.md @@ -1,8 +1,10 @@ ## 예찬 + 현재까지 작성한 회원가입 구현 과정에서 TDD로 개발을 진행했는데, 외부에 의존된 코드가 통합테스트에 다수 포함되어있음. 이를 최소화할 방안이 필요하다고 느낌. ## 휘동 + 회원가입 요청에 사용되는 `MemberRegisterRequest` 객체의 속성이 하드코딩되어있는 부분이 많음. -이를 메서드로 추출하여 테스트 신뢰성을 높이는 방향으로 개선할 필요가 있음. \ No newline at end of file +이를 메서드로 추출하여 테스트 신뢰성을 높이는 방향으로 개선할 필요가 있음. diff --git a/docs/devlog/250819.md b/docs/devlog/250819.md index 4601e48..a0105d7 100644 --- a/docs/devlog/250819.md +++ b/docs/devlog/250819.md @@ -1,10 +1,11 @@ ## 예찬 -시스템 아키텍처에 대한 개념이 흐리다고 느낌. 특히 정확한 기준 없이 헥사고날 아키텍처를 적용하려 했던 부분이 문제가 된것으로 생각됨. -또한 TDD를 위한 `TestUtils`를 구현하는데에 있어 발생한 문제를 아직 해결하지 못함. 성공적인 응답값을 반환해야 하는 경우에 적절한 에러 메세지를 띄우지 않기 때문에 문제 해결에 어려움이 있음. +시스템 아키텍처에 대한 개념이 흐리다고 느낌. 특히 정확한 기준 없이 헥사고날 아키텍처를 적용하려 했던 부분이 문제가 된것으로 생각됨. +또한 TDD를 위한 `TestUtils`를 구현하는데에 있어 발생한 문제를 아직 해결하지 못함. 성공적인 응답값을 반환해야 하는 경우에 적절한 에러 메세지를 띄우지 않기 때문에 문제 해결에 어려움이 있음. ## 휘동 + 헥사고날의 In/Out 개념을 확실히 하고 넘어가야할 것 같음. 응답 객체에서 원하는 응답 값과 상태를 반환하지 못하는 문제가 있음. -응답 객체를 간소화하거나 래퍼 메소드를 수정할 필요가 있음. \ No newline at end of file +응답 객체를 간소화하거나 래퍼 메소드를 수정할 필요가 있음. diff --git a/docs/devlog/250821.md b/docs/devlog/250821.md index 8a1c434..ed3fce8 100644 --- a/docs/devlog/250821.md +++ b/docs/devlog/250821.md @@ -1,11 +1,21 @@ ## 예찬 -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`에 등록하도록 객체를 생성해야 중복 등록이 발생하지 않는다. + +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 +filterchain.doFilter()의 유무로 필터의 중단을 결정하지 말고, GenericFilterBean와 OncePerRequestFilter를 적절히 사용해 필터 적용 시기를 조절함이 바람직하다. diff --git a/docs/devlog/250829.md b/docs/devlog/250829.md index db3efa7..6785a61 100644 --- a/docs/devlog/250829.md +++ b/docs/devlog/250829.md @@ -1,5 +1,7 @@ ## 예찬 -전반적인 인증 흐름에 대한 이해가 이번 기능 구현 과정에서 미흡했던것을 인지함. 이후 Spring Security의 인증 흐름을 학습하고, 그 과정에서 기존에 구현하려 했던 방식에 문제점을 발견했고, 이를 해결하기 위해 직접 제어 가능한 수준의 인증정보 제공자를 직접 구현하는 방식으로 기능 구현, 성공적인 테스트 케이스 통과가 가능했다. + +전반적인 인증 흐름에 대한 이해가 이번 기능 구현 과정에서 미흡했던것을 인지함. 이후 Spring Security의 인증 흐름을 학습하고, 그 과정에서 기존에 구현하려 했던 방식에 문제점을 발견했고, 이를 해결하기 +위해 직접 제어 가능한 수준의 인증정보 제공자를 직접 구현하는 방식으로 기능 구현, 성공적인 테스트 케이스 통과가 가능했다. 이후에 해야할건 영화 조회 아닐까 싶다. 영화 조회 기능도 일단 빠르게 Spring Data JPA의 도움을 받아서 구현하고 이후 최적화 과정을 거치는 것이 좋지 않을까 하는 생각. 추가로, 현재까지 개발을 하는 과정에서 정책을 일부 작성해왔었는데, 이부분이 좀 누락된거 같다. 작성해두는게 앞으로 문제 발생 가능성을 줄이는데에 도움이 되지 않을까... diff --git a/docs/devlog/250908.md b/docs/devlog/250908.md index 044bf0b..ef5e131 100644 --- a/docs/devlog/250908.md +++ b/docs/devlog/250908.md @@ -1,7 +1,11 @@ ## 예찬 -기존의 개발 방식을 고수하면서 이번에는 Aggregate root간의 의존을 최소화 하기 위해 Application Event를 적당히 활용해봤다. 실무에서도 이렇게 잘 쓰이는지는 모르겠다만, 결국 Spring Context에 의해 관리되는 영역인 만큼 그냥 써도 되지 않을까 하는 생각.. +기존의 개발 방식을 고수하면서 이번에는 Aggregate root간의 의존을 최소화 하기 위해 Application Event를 적당히 활용해봤다. 실무에서도 이렇게 잘 쓰이는지는 모르겠다만, 결국 Spring +Context에 의해 관리되는 영역인 만큼 그냥 써도 되지 않을까 하는 생각.. -그리고 이번에 AR를 적극적으로 활용한 DDD를 하려 하다보니 Entity를 package private로 개발하는 방식을 처음으로 써봤다. 도메인 로직들은 통상 AR를 통해 호출되기 때문에, AR를 제외한 나머지 Entity들은 외부에서 직접 접근할 일이 거의 없다는 점에 착안한 것이다. 물론, 이 방식이 무조건 옳다고 생각하지는 않는다. 다만, 이번 프로젝트에서는 이 방식을 채택함으로써 도메인 모델의 캡슐화가 좀 더 강화된 느낌이다. +그리고 이번에 AR를 적극적으로 활용한 DDD를 하려 하다보니 Entity를 package private로 개발하는 방식을 처음으로 써봤다. 도메인 로직들은 통상 AR를 통해 호출되기 때문에, AR를 제외한 나머지 +Entity들은 외부에서 직접 접근할 일이 거의 없다는 점에 착안한 것이다. 물론, 이 방식이 무조건 옳다고 생각하지는 않는다. 다만, 이번 프로젝트에서는 이 방식을 채택함으로써 도메인 모델의 캡슐화가 좀 더 강화된 +느낌이다. -마침 AR를 사용한 설계의 장점을 극대화할 겸, 어차피 나중에 한번쯤은 손대려고 했던 모듈을 다음 기능 개발에 도입하려 한다. 이때 AR 기준 접근을 하려 했던 기존의 설계가 빛을 바랄거라고 생각되는데, 이 부분은 다음 개발기를 통해 좀 더 자세히 다뤄보도록 하겠다. +마침 AR를 사용한 설계의 장점을 극대화할 겸, 어차피 나중에 한번쯤은 손대려고 했던 모듈을 다음 기능 개발에 도입하려 한다. 이때 AR 기준 접근을 하려 했던 기존의 설계가 빛을 바랄거라고 생각되는데, 이 부분은 +다음 개발기를 통해 좀 더 자세히 다뤄보도록 하겠다. diff --git a/docs/devlog/250914.md b/docs/devlog/250914.md new file mode 100644 index 0000000..1df9373 --- /dev/null +++ b/docs/devlog/250914.md @@ -0,0 +1,80 @@ +## 예찬 + +프로젝트 전반의 구조에 대해 확장 가능성이 충분히 존재할것으로 예상되어 빌드 구조적, 도메인 개념적 구조화된 분리를 할 필요성이 있다고 느껴져 모듈화 작업을 진행했다. + +기존의 싱글모듈 프로젝트에서 멀티모듈로 전환하는 과정을 거쳤다. 변경된 모듈 구조는 아래와 같다. + +Root: 프로젝트 최상단. 모든 모듈에게 공통적으로 사용되는 의존성을 관리한. test, lombok, null-safety 관련 의존성이 존재한다. + +- Internal: 애플리케이션 내부의 생태계를 관리한다. 직접적인 비즈니스 관리영역이 아닌 '애플리케이션' 자체를 관리한다. 로그 설정, web 설정, 보안 설정등 비즈니스 요구사항을 직접적으로 나타내지 않는 + 구현들이 존재한다. +- External: 외부 세계와의 통신을 담당한다. 도메인 로직은 물론 애플리케이션과도 완전 독립적인 모듈이다. MQ, STMP등등에 대한 기능의 구현이 존재한다. +- Domain: 비즈니스 영역의 핵심이 되는 영역이다. 비즈니스를 해결하기 위한 도메인 그 자체를 의미하며 도메인 개념을 로직으로 풀어나가는 영역이다. Entity와 통신 객체들이 여기에 해당한다. +- Common: 공통코드들을 관리한다. 파급효과가 가장 큰 영역인 만큼 라이브러리 사용을 방지하고 POJO 스타일을 원칙으로 한다. 상수와 type object들이 존재한다. +- Application: 모든 영역들을 통합해 애플리케이션을 만들어 관리한다. Spring boot의 main class가 존재하며, 각 모듈들을 통합해 비즈니스 요구사항을 해결한다. 비즈니스 로직을 해결하는 + 영역과 이를 전달하는 영역으로 대부분의 Service 영역과 Controller영역, 그리고 통합 테스트가 존재한다. + +그래서 모듈 구조를 정리해보자면, + +```mermaid +flowchart + Application --> Domain + Application --> Common + Application --> Internal + Application --> External + Domain --> Common + Internal --> Common + +``` + +조금더 영역을 나눠본다면 다음과 같을것이다. + +```mermaid +flowchart TB + subgraph L4["Application"] + A2[Controller] + A3[Service] + A4[통합 테스트] + end + + subgraph L3["Infrastructure"] + subgraph Internal["Internal"] + I1[Logging] + I2[Web Configurations] + I3[Security Configurations] + end + subgraph External["External"] + E1[MQ] + E2[SMTP] + E3[External API] + end + end + + subgraph L2["Domain"] + D1[Entity] + D2[DTO/VO] + end + + subgraph L1["Common"] + C1[enum] + C2[Type Objects] + end + +%% 의존 관계 (상위 → 하위) + A2 --> A3 + A3 --> D1 + A3 --> Internal + A3 --> External + D1 --> L1 + A2 --> D2 + A3 --> D2 + Internal --> L1 + External --> L1 + +``` + +참고로 테스트는 따지자면 그 어떤 영역에도 의존하지 않아야한다. 테스트는 만들어진 기능의 가장 첫 사용자이기 때문에 원칙대로라면 그 어떤 모듈에도 의존하지 않아야한다. 다만 개발 편의를 위해 일부 의존된 부분이 +있으니 어느정도 수용하고 넘어갈 수 있는 부분인거 같다. + +그리고 이제 패키지와 모듈 관계를 정리하며 외부와 소통할 클래스만 public을 사용한다. java의 클래스 기본 접근 제한자가 package-private인 이유는 필요한 기능들에 대해서만 외부에 노출하고 그 +외에는 외부에서 신경쓰지 않도록 하기 위해서다. 그 의도에 맞게 적당히 필요한 경우에만 public을 사용하고 나머지는 package-private으로 유지한다. diff --git a/docs/specs/api/login.md b/docs/specs/api/login.md index ad8291b..df6f368 100644 --- a/docs/specs/api/login.md +++ b/docs/specs/api/login.md @@ -3,13 +3,13 @@ - 메서드: `POST` - 경로: `/api/auth/login` - 헤더 - + ``` Content-Type: application/json ``` - + - 본문 - + ```json { "userId": "string", @@ -17,10 +17,10 @@ } ``` - + - curl 명령 예시 - + ```bash curl -i -X POST 'http://localhost:8080/api/auth/login' \ -H 'Content-Type: application/json' \ @@ -30,20 +30,18 @@ "password": "myPassword123" }' ``` - ### 응답 - 상태코드: `200 OK` - 본문 - + ```json { "accessToken": "string", "refreshToken": "string" } ``` - ### 테스트 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index cf2c9f1..cab60ce 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -4,21 +4,26 @@ 이 문서는 공연 예매 시스템의 도메인 모델 설계 및 주요 유스케이스 흐름을 설명합니다. 설계는 도메인 모델 패턴을 따르며, 다음 원칙을 강제합니다. + - AR 내부 연관은 FK 사용 허용 - AR 간 연관은 "간접 참조(식별자)"만 사용(FK 불허, via XId) - 결제 도메인 분리 없음: Reservation AR 내부에 Payment/PaymentAttempt/Refund 포함 - Show 공연 기간 명확화: performanceStartDate, performanceEndDate(또는 값객체 PerformanceWindow) 사용 --- + # 공연 예매 시스템 도메인 설계 --- ## 공연(Show) + _Aggregate Root_ + - 공연 작품 자체 #### 속성 + - 제목(title) - 유형(type: MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) - 관람등급(rating: ALL, AGE12, AGE15, AGE18) @@ -29,10 +34,12 @@ _Aggregate Root_ - 공연 스케줄(schedules: List\) #### 행위 + - `create(command: ShowCreateCommand)` -- `registerSchedule(hallId, startAt, endAt, runtimeOverride)` +- `registerSchedule(hallId, startAt, endAt)` #### 관련 타입 + - `ShowCreateCommand` - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate - `ShowRegisterRequest` / `ShowRegisterResponse` @@ -40,10 +47,13 @@ _Aggregate Root_ --- ### 회차(ShowSchedule) + _Entity_ + - 특정 공연이 특정 홀에서 특정 시간에 진행되는 스케줄 #### 속성 + - showId(FK) - hallId - 시작일시(startAt) @@ -53,10 +63,13 @@ _Entity_ --- ### 캐스팅(Casting) + _Entity_ + - 회차별 배역과 출연자 매핑 #### 속성 + - scheduleId(FK) - 배역명(roleName) - 출연자명(personName) @@ -64,10 +77,13 @@ _Entity_ --- ## 공연장(Venue) + _Aggregate Root_ + - 공연 시설 #### 속성 + - 이름(name) - 주소(address) @@ -80,20 +96,26 @@ _Aggregate Root_ --- ### 홀(Hall) + _Entity_ + - 공연장 내부의 개별 공간 #### 속성 + - venueId(FK) - 이름(name) --- ### 좌석(Seat) + _Entity_ + - 홀 내부의 개별 좌석 #### 속성 + - hallId(FK) - 열(rowLabel) - 번호(number) @@ -103,20 +125,26 @@ _Entity_ --- ### 좌석등급(TicketGrade) + _Entity_ + - 홀 단위 좌석 등급 #### 속성 + - hallId(FK) - 이름(name) --- ## 가격표(SchedulePricing) + _Aggregate Root_ + - 회차와 좌석등급 조합에 따른 가격 관리 #### 속성 + - scheduleId - 통화(currency) - 시작일(validFrom) @@ -124,21 +152,26 @@ _Aggregate Root_ - 가격정책(pricingPolicy) #### 행위 + - `createFor(scheduleId, currency, validFrom, validTo)` - `putPrice(ticketGradeId, amount)` - `removePrice(ticketGradeId)` #### 관련 타입 + - `CreateSchedulePricingCommand` - `PutTicketPriceCommand` --- ### 가격행(TicketPriceLine) + _Entity_ + - 가격표의 라인 항목 #### 속성 + - schedulePricingId(FK) - ticketGradeId - 금액(amount) @@ -146,10 +179,13 @@ _Entity_ --- ## 회원(Member) + _Aggregate Root_ + - 서비스를 사용하는 회원 #### 속성 + - 닉네임(nickName) - 아이디(userId, UNIQUE) - 비밀번호(passwordHash) @@ -157,22 +193,27 @@ _Aggregate Root_ - 권한(authorities: USER, DISTRIBUTOR, ADMIN) #### 행위 + - `register(command: MemberRegisterCommand, encoder)` - `changeNickName(newNickName)` - `changeEmail(newEmail)` - `matchesPassword(rawPassword, encoder)` #### 관련 타입 + - `MemberRegisterCommand` - `MemberRegisterRequest` / `MemberRegisterResponse` --- ## 예매(Reservation) + _Aggregate Root_ + - 좌석 보류, 확정, 환불 및 결제 관리 #### 속성 + - memberId - scheduleId - seatId @@ -183,6 +224,7 @@ _Aggregate Root_ - 결제금액(paidAmount) #### 행위 + - `hold(memberId, scheduleId, seatId, ticketGradeId, ttl)` - `readyPayment(merchantUid, totalAmount)` - `confirmPaid(merchantUid, approvedAt)` @@ -190,6 +232,7 @@ _Aggregate Root_ - `requestRefund(amount, reason)` #### 관련 타입 + - `HoldReservationCommand` - `ReadyPaymentCommand` - `ConfirmPaidCommand` @@ -198,10 +241,13 @@ _Aggregate Root_ --- ### 결제(Payment) + _Entity_ + - Reservation에 종속되는 결제 정보 #### 속성 + - reservationId(FK) - 상점거래ID(merchantUid, UNIQUE) - 총액(totalAmount) @@ -211,10 +257,13 @@ _Entity_ --- ### 결제시도(PaymentAttempt) + _Entity_ + - 결제 요청 및 승인/실패 내역 #### 속성 + - paymentId(FK) - 결제수단(method: CARD, ACCOUNT_TRANSFER, MOBILE, VIRTUAL_ACCOUNT, SIMPLE_PAY) - 요청금액(requestedAmount) @@ -227,10 +276,13 @@ _Entity_ --- ### 환불(Refund) + _Entity_ + - 환불 내역 #### 속성 + - paymentId(FK) - 환불금액(amount) - 상태(refundStatus: REQUESTED, PENDING, COMPLETED, FAILED) diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md index 62a1b93..5f15a03 100644 --- a/docs/specs/policy/application.md +++ b/docs/specs/policy/application.md @@ -1,104 +1,190 @@ # 애플리케이션 아키텍처 규칙(헥사고날 아키텍처) -본 문서는 booking 프로젝트가 채택한 헥사고날 아키텍처(Hexagonal Architecture, Ports & Adapters)의 규칙을 명확히 하기 위한 가이드입니다. 이 문서는 아키텍처 테스트와 코드 리뷰의 근거가 되며, 새로운 기능 추가 시 반드시 준수해야 합니다. +본 문서는 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) +- domain 모듈 + +--- + +- common 모듈 +- **위치**: `./common` +- **책임**: 모든 모듈이 공통적으로 사용할 수 있는 순수 코드 + - Enum + - Type Object +- **특징**: + - 외부 라이브러리 의존 금지 + - POJO 스타일 유지 + +--- + +- domain 모듈 +- **위치**: `./domain` +- **책임**: 비즈니스 핵심 모델과 규칙 + - Entity + - DTO/VO +- **의존성**: Common 모듈에만 의존 가능 + +--- + +- Internal 모듈 + +- **위치**: `./internal` +- **책임**: 애플리케이션 내부 생태계 관리 + - Logging + - Web Configurations + - Security Configurations +- **의존성**: Common 모듈에 의존 가능 +- External 모듈 +- **위치**: `./external` +- **책임**: 외부 세계와의 연결 + - MQ + - SMTP + - External API +- **의존성**: Common 모듈에 의존 가능 + +--- + +## 4. Application 모듈 + +- **위치**: `./application` +- **책임**: 애플리케이션 통합 및 유스케이스 실행 + - Controller + - Service + - 통합 테스트 +- **의존성** + - Domain 모듈 + - Common 모듈 + - Internal 모듈 + - External 모듈 + +--- ## 2. 의존성 규칙 - domain -> another domain -- app -> domain (OK), adapter (금지) -- adapter -> app 포트(OK), app 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 app 경유) +- application -> domain (OK), adapter (금지) +- adapter -> application 포트(OK), application 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 application 경유) - DTO/엔티티 경계: - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. ## 3. 포트와 어댑터 -- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `app/port` 컨트롤러는 입력 포트를 통해서만 유스케이스 호출. -- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `app/persist` 또는 `app/port` 하위에 정의 가능. -- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: adapter 하위. 현재 JPA 기반 구현은 `app/persist/*Repository`를 통해 동작하며, 해당 패키지는 어댑터 계층으로 간주합니다. +- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `application/aggregate-root` 컨트롤러는 입력 포트를 통해서만 유스케이스 호출. +- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `application/aggregate-root` 또는 `application/aggregate-root` 하위에 정의 + 가능. +- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: adapter 하위. 현재 JPA 기반 구현은 `application/aggregate-root/*Repository`를 통해 동작하며, 해당 패키지는 + 어댑터 계층으로 + 간주합니다. 권장 네이밍: + - 입력 포트: UseCase 동사형 + er (예: Registerer, UseCase) - 출력 포트: 리소스 + 동작 + Repository/Gateway (예: ShowCommandRepository) - 그 외에는 해당 인터페이스가 담당한 기능의 추상적 개념을 나타내는 네이밍 ## 4. 트랜잭션/검증/예외/로깅 규칙 -- 트랜잭션 경계: app 계층의 유스케이스 서비스 메서드 수준에서 관리(@Transactional). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. +- 트랜잭션 경계: application 계층의 유스케이스 서비스 메서드 수준에서 관리(`@Transactional`). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. - 검증: - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. - - 비즈니스/정책 검증: app 또는 domain에서 수행. `Validator` 등의 컴포넌트는 app에 위치. + - 비즈니스/정책 검증: application 또는 domain에서 수행. `Validator` 등의 컴포넌트는 application에 위치. - 예외: - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. - - 어댑터/기술 오류는 해당 계층에서 포착하고 app/domain 의미의 예외로 변환 또는 적절히 매핑. + - 어댑터/기술 오류는 해당 계층에서 포착하고 domain 의미의 예외로 변환 또는 적절히 매핑. - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. -- 로깅: 크로스커팅은 app 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. - -## 5. 패키지 구조 규칙 - -- domain: `org.mandarin.booking.domain.{boundedContext}` - - 예: `domain.member`, `domain.show` -- app: `org.mandarin.booking.app` - - 하위: `port`, `persist`(출력 포트/구현), 서비스 클래스 -- adapter: `org.mandarin.booking.adapter.{webapi|security|...}` -- 순환 의존 금지: 위 규칙 위반 시 컴파일/테스트 단계에서 아키텍처 테스트 실패로 간주. +- 로깅: 크로스커팅은 internal 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. + +## 5. 모듈 구조 규칙 + +- internal: 애플리케이션 내부의 생태계를 관리한다. 직접적인 비즈니스 관리영역이 아닌 '애플리케이션' 자체를 관리한다. 로그 설정, web 설정, 보안 설정등 비즈니스 요구사항을 직접적으로 나타내지 않는 + 구현들이 존재한다. +- external: 외부 세계와의 통신을 담당한다. 도메인 로직은 물론 애플리케이션과도 완전 독립적인 모듈이다. MQ, STMP등등에 대한 기능의 구현이 존재한다. +- domain: 비즈니스 영역의 핵심이 되는 영역이다. 비즈니스를 해결하기 위한 도메인 그 자체를 의미하며 도메인 개념을 로직으로 풀어나가는 영역이다. Entity와 통신 객체들이 여기에 해당한다. +- common: 공통코드들을 관리한다. 파급효과가 가장 큰 영역인 만큼 라이브러리 사용을 방지하고 POJO 스타일을 원칙으로 한다. 상수와 type object들이 존재한다. +- application: 모든 영역들을 통합해 애플리케이션을 만들어 관리한다. Spring boot의 main class가 존재하며, 각 모듈들을 통합해 비즈니스 요구사항을 해결한다. 비즈니스 로직을 해결하는 + 영역과 이를 전달하는 영역으로 대부분의 Service 영역과 Controller영역, 그리고 통합 테스트가 존재한다. + +### 5.1 모듈간 의존관계 + +```mermaid +flowchart TB + subgraph L4["Application"] + A2[Controller] + A3[Service] + A4[통합 테스트] + end + + subgraph L3["Infrastructure"] + subgraph Internal["Internal"] + I1[Logging] + I2[Web Configurations] + I3[Security Configurations] + end + subgraph External["External"] + E1[MQ] + E2[SMTP] + E3[External API] + end + end + + subgraph L2["Domain"] + D1[Entity] + D2[DTO/VO] + end + + subgraph L1["Common"] + C1[enum] + C2[Type Objects] + end + + A2 --> A3 + A3 --> D1 + A3 --> Internal + A3 --> External + D1 --> L1 + A2 --> D2 + A3 --> D2 + Internal --> L1 + External --> L1 + +``` ## 6. 컨트롤러와 DTO 변환 규칙 - 컨트롤러는 입력 포트만 의존한다. -- 요청 DTO -> domain/app 요청 모델로 변환 후 유스케이스 호출. +- 요청 DTO -> domain/application 요청 모델로 변환 후 유스케이스 호출. - 유스케이스 반환값 -> web DTO로 매핑하여 응답한다. - 컨트롤러에서 비즈니스 로직/트랜잭션 처리 금지. 예시(공연 등록): -- `adapter/webapi/ShowController` -> `app/port/ShowRegisterer` 호출 +- `adapter/webapi/ShowController` -> `application/port/ShowRegisterer` 호출 - `domain.show.ShowRegisterRequest`/`ShowCreateCommand` 사용하여 유스케이스 실행 - 결과를 `domain.show.ShowRegisterResponse` 받아 web 응답으로 래핑(`ApiResponse`) ## 7. 영속성 규칙(JPA) -- JPA 엔티티는 domain에, Repository는 app(persist)에만 존재. -- app 계층은 JPA 구체 타입에 의존하지 않고, 출력 포트 인터페이스를 통해서만 데이터 접근. +- JPA 엔티티는 domain에, Repository는 application(persist)에만 존재. +- application 계층은 JPA 구체 타입에 의존하지 않고, 출력 포트 인터페이스를 통해서만 데이터 접근. - 매핑 책임은 어댑터에 위치: JPA 엔티티 <-> 도메인 엔티티/모델 변환. ## 8. 보안 규칙 - 인증/인가 컴포넌트는 adapter/security에 위치: `JwtFilter`, `CustomAuthenticationProvider`, `SecurityConfig` 등. -- 보안 컨텍스트와 토큰 파싱은 어댑터에서 처리하고, app 유스케이스에는 인증된 식별자/역할만 전달. +- 보안 컨텍스트와 토큰 파싱은 어댑터에서 처리하고, application 유스케이스에는 인증된 식별자/역할만 전달. ## 9. 테스트 규칙 - `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` 는 아키텍처 규칙을 자동 검증합니다. - 규칙 위반 예: - - adapter가 app 서비스 구현 클래스에 직접 의존 - - app이 adapter 패키지에 의존 + - adapter가 application 서비스 구현 클래스에 직접 의존 + - application이 adapter 패키지에 의존 - domain이 프레임워크에 의존 - 새로운 모듈/클래스 추가 시 해당 테스트가 통과하는지 반드시 확인합니다. @@ -107,29 +193,33 @@ 새 유스케이스(예: 공연 수정) 추가 절차: 1) domain에 필요한 모델/명세 정의(예: `ShowUpdateCommand`). -2) app/port에 입력 포트 정의(예: `ShowUpdater`). -3) app에 서비스 구현(`ShowService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. +2) application/port에 입력 포트 정의(예: `ShowUpdater`). +3) application에 서비스 구현(`ShowService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. 4) 필요 시 출력 포트 정의 및 어댑터 구현(persist/JPA 등). 5) adapter/webapi에 컨트롤러 엔드포인트 추가 및 DTO 매핑. 6) 아키텍처/통합 테스트 통과 확인. 새 어댑터(예: 외부 결제 API) 추가 절차: -1) app에 출력 포트 인터페이스 추가(예: `PaymentGateway`). + +1) application에 출력 포트 인터페이스 추가(예: `PaymentGateway`). 2) adapter 하위에 구현(예: `adapter/external/PaymentGatewayHttpClient`). 3) 구성(Security/Config)과 예외 매핑 추가. ## 11. 공통 규칙 요약(Do/Don’t) Do -- 유스케이스 입출력은 app 포트를 통해서만 노출/호출한다. + +- 유스케이스 입출력은 application 포트를 통해서만 노출/호출한다. - 도메인 모델은 순수하게 유지한다(프레임워크 의존 금지). - 어댑터는 포트 인터페이스를 구현한다. -- 트랜잭션과 로깅은 app에서 관리한다. +- 트랜잭션과 로깅은 application에서 관리한다. Don’t + - 컨트롤러에서 비즈니스 로직 수행 금지. -- app에서 adapter 패키지/구현에 의존 금지. +- application에서 adapter 패키지/구현에 의존 금지. - domain에서 JPA/Spring 등에 의존 금지. +- 모듈간 의존관계를 위배하는 구현 금지. ## 12. 용어 diff --git a/docs/specs/policy/authentication.md b/docs/specs/policy/authentication.md index 206ac49..0264c64 100644 --- a/docs/specs/policy/authentication.md +++ b/docs/specs/policy/authentication.md @@ -3,22 +3,26 @@ 본 문서는 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/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(...)` 위임 @@ -28,12 +32,14 @@ --- ## 2. JWT 토큰 규칙 + - 헤더 키: `Authorization` - 포맷: `Bearer ` - 사용 Claims (근거: JwtFilter): - `userId`: 사용자 식별자 - `roles`: 문자열 리스트. 예: `["ROLE_USER", "ROLE_DISTRIBUTOR"]` -- 권한 매핑: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 뒤 `MemberAuthority` enum으로 변환하여 `CustomMemberAuthenticationToken`에 부여합니다. (근거: JwtFilter.getAuthorities) +- 권한 매핑: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 뒤 `MemberAuthority` enum으로 변환하여 `CustomMemberAuthenticationToken`에 + 부여합니다. (근거: JwtFilter.getAuthorities) - 토큰 서명/TTL 설정: 프로필 별 설정 사용 - `jwt.token.secret`: 서명 시크릿(Base64 인코딩 값) - `jwt.token.access`: Access Token 만료(ms) @@ -41,12 +47,15 @@ - 근거: `application-local.yml`, `application-test.yml` 주의: -- 헤더가 없거나 `Bearer` 접두사만 온 경우 익명 처리되며, 요청 속성 `exception`에 `AuthException`이 설정됩니다. (근거: JwtFilter.isTokenBlank, doFilterInternal) + +- 헤더가 없거나 `Bearer` 접두사만 온 경우 익명 처리되며, 요청 속성 `exception`에 `AuthException`이 설정됩니다. (근거: JwtFilter.isTokenBlank, + doFilterInternal) - 서명 오류/만료/클레임 파싱 실패 시 `AuthException`을 설정하고 SecurityContext를 비웁니다. (근거: JwtFilter 예외 처리) --- ## 3. 인증 컴포넌트 + - JwtFilter - 위치/순서: `UsernamePasswordAuthenticationFilter` 앞 (근거: SecurityConfig.addFilterBefore) - 역할: Authorization 헤더 파싱, 사용자 정보 추출, AuthenticationManager 위임, SecurityContext 설정 @@ -59,13 +68,16 @@ --- ## 4. 인증 예외 처리 + - 인증 실패(미인증) 시: `CustomAuthenticationEntryPoint`가 응답 생성 (상세 형식은 클래스 구현 참고) - 인가 실패(권한 부족) 시: `CustomAccessDeniedHandler`가 응답 생성 -- JwtFilter는 내부적으로 `request.setAttribute("exception", new AuthException(...))`로 실패 사유를 넘기며, 이후 예외 처리기가 이를 사용해 응답을 형성할 수 있습니다. +- JwtFilter는 내부적으로 `request.setAttribute("exception", new AuthException(...))`로 실패 사유를 넘기며, 이후 예외 처리기가 이를 사용해 응답을 형성할 수 + 있습니다. --- ## 5. 공개 엔드포인트와 인증 필요 엔드포인트 + - 공개(permitAll): (근거: SecurityConfig.apiChain) - `POST /api/member` - `POST /api/auth/login` @@ -78,6 +90,7 @@ --- ## 6. 테스트/프로필 연계 + - 테스트 실행 시 프로필 `test` 활성화: Gradle test 태스크에서 설정 (근거: build.gradle) - 테스트 프로필에서 JWT/DB 설정은 `application-test.yml`을 따른다. - 보안 관련 통합/단위 테스트는 다음을 참조: `src/test/java/org/mandarin/booking/adapter/security/*` @@ -85,6 +98,7 @@ --- ## 7. 확장 가이드(인증) + - 새로운 인증 스킴 도입 시 지켜야 할 규칙: - JwtFilter 앞/뒤 필터 추가 시 순서 충돌 주의. 인증 헤더 파싱 필터는 반드시 UsernamePasswordAuthenticationFilter 이전. - CustomAuthenticationProvider는 `supports`/`authenticate` 계약을 준수하여 `Authentication` 토큰 타입을 명확히 구분. @@ -94,6 +108,7 @@ --- ## 8. 알 수 없는 항목(확인 불가) + - 토큰 발급/서명 구현 세부(Access/Refresh 생성 로직) 문서화 수준: 확인 불가 (해당 클래스 상세는 별도 코드 참조 필요) - 키 회전/블랙리스트/토큰 철회 전략: 확인 불가 diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md index 120471b..632a0c4 100644 --- a/docs/specs/policy/authorization.md +++ b/docs/specs/policy/authorization.md @@ -3,20 +3,25 @@ 본 문서는 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` +- 예외 처리기: `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) +- 권한 문자열은 `ROLE_` 접두사를 가진 형태로 JWT `roles` 클레임에 담깁니다. (예: `ROLE_USER`, `ROLE_DISTRIBUTOR`, `ROLE_ADMIN`) (근거: + JwtFilter.getAuthorities, MemberAuthority) --- ## 2. 경로/메서드 별 인가 규칙 + 아래 표는 `SecurityConfig.apiChain`의 `authorizeHttpRequests` 설정을 반영합니다. - 공개(permitAll): @@ -34,6 +39,7 @@ - `/error`, `/assets/**`, `/favicon.ico`, 및 그 외 `/**`는 permitAll (정적/오류/기타 공개 경로) 근거 코드 스니펫 요약: + - `http.securityMatcher("/api/**")` - `.requestMatchers(HttpMethod.POST, "/api/member").permitAll()` - `.requestMatchers("/api/auth/login").permitAll()` @@ -44,14 +50,17 @@ --- ## 3. 권한 명명 규칙과 매핑 + - Enum: `MemberAuthority`는 다음 권한을 가집니다(코드 참조). - USER, DISTRIBUTOR, ADMIN 등 -- JWT `roles` → Spring Security 권한 변환: JwtFilter는 `roles`에서 `ROLE_` 접두사를 제거한 후 `MemberAuthority.valueOf(...)`로 Enum을 만들어 `CustomMemberAuthenticationToken`에 저장합니다. (근거: JwtFilter.getAuthorities) +- 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)`을 설정하여 원인 정보를 예외 처리기로 전달합니다. @@ -59,6 +68,7 @@ --- ## 5. 확장 가이드(인가 규칙 추가 방법) + - 새로운 엔드포인트 추가 시 규칙 예시: - 공개 엔드포인트(회원가입/로그인 유사): `.requestMatchers(HttpMethod.POST, "/api/xxx").permitAll()` - 역할 제한 엔드포인트: `.requestMatchers(HttpMethod.PUT, "/api/show/{id}").hasAuthority("ROLE_ADMIN")` @@ -69,5 +79,6 @@ --- ## 6. 알 수 없는 항목(확인 불가) + - 경로 별 세부 권한 정책 문서(도메인별 Role Matrix): 현재 저장소에 상세 표 없음 → 확인 불가 - 동적 권한(도메인 데이터 소유권 기반 세분화) 정책: 확인 불가 diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index b401862..d4ab220 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -1,8 +1,10 @@ # 테스트 정책 (booking) -본 문서는 booking 프로젝트의 테스트 작성/실행 표준을 정의합니다. 실제 저장소의 테스트 코드, Gradle 설정, 애플리케이션 프로필을 근거로 수립되었습니다. 이 문서는 코드 변경 시 항상 최신 상태로 유지되어야 하며, 테스트 실패는 정책 위반으로 간주할 수 있습니다. +본 문서는 booking 프로젝트의 테스트 작성/실행 표준을 정의합니다. 실제 저장소의 테스트 코드, Gradle 설정, 애플리케이션 프로필을 근거로 수립되었습니다. 이 문서는 코드 변경 시 항상 최신 상태로 +유지되어야 하며, 테스트 실패는 정책 위반으로 간주할 수 있습니다. 근거 파일/경로: + - build.gradle: test 태스크, 라이브러리, JVM args 설정 - src/main/resources/application-test.yml: 테스트 프로필 환경 - src/test/java/**/*: 실제 테스트 코드 일체 @@ -11,11 +13,13 @@ --- ## 1. 목표와 범위 + - 목표: 기능/아키텍처/보안/도메인 규칙을 신뢰성 있게 검증한다. - 범위: 단위 테스트(Unit), 통합 테스트(Integration), 아키텍처 테스트(ArchUnit) 전반. - 테스트 실행 환경은 Gradle test 태스크를 표준으로 한다. 명령: + - 전체 테스트: `./gradlew test` - 테스트 프로필: Gradle가 자동으로 `spring.profiles.active=test`를 설정함 (build.gradle 근거). @@ -24,12 +28,14 @@ ## 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` + - 보안 컴포넌트 단위: `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'` @@ -37,20 +43,23 @@ - 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` 등 공용 유틸을 통해 테스트 준비/토큰 생성/컨텍스트 초기화. +- 유틸리티: `IntegrationTest`, `IntegrationTestUtils`, `IntegrationTestUtilsSpecs`, `JwtTestUtils` 등 공용 유틸을 통해 테스트 준비/토큰 + 생성/컨텍스트 초기화. - 예시 근거: - - 웹 API 스펙 테스트: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` + - 웹 API 스펙 테스트: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` - 통합 환경 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java` -권장 규칙: + 권장 규칙: - `@IntegrationTest` 커스텀 어노테이션 사용으로 공통 설정 - 각 테스트는 `IntegrationTestUtils`를 사용해 작성 - `IntegrationTestUtils` 사용 방법은 다음과 같음 @@ -64,7 +73,7 @@ assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); } ``` - - ```java + - ```java @Test void failToAuth(@Autowired IntegrationTestUtils testUtils) { // Arrange @@ -85,6 +94,7 @@ - 검증은 최대한 `assertj`의 `assertThat`을 사용해 검증 통일. ### 2.3 아키텍처 테스트 (ArchUnit) + - 목적: 헥사고날 계층 규칙 준수 보장. - 근거 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` - 핵심 규칙: @@ -95,6 +105,7 @@ --- ## 3. 테스트-환경 설정 + - Gradle test 태스크가 `spring.profiles.active=test`로 실행됨: `build.gradle` 65~70행 참고. - H2 설정: `application-test.yml` - URL: `jdbc:h2:mem:test;MODE=MySQL;` @@ -108,6 +119,7 @@ ## 4. 작성 규칙 ### 4.1 단위 테스트 작성 체크리스트 + - [ ] 단일 책임/작은 단위만 검증한다. - [ ] 외부 의존은 Mockito로 모킹한다. - [ ] 스프링 컨텍스트를 기동하지 않는다. @@ -115,6 +127,7 @@ - [ ] 경계값, 널/빈값 케이스 포함. ### 4.2 통합 테스트 작성 체크리스트 + - [ ] Spring 컨텍스트 기동 및 필요한 빈 주입 확인. - [ ] 보안 필터/JWT 인증 흐름을 실제로 검증한다. - [ ] 컨트롤러 → 앱 서비스 → 영속성(JPA/H2) 경로를 통해 상태 변화/응답을 확인한다. @@ -122,6 +135,7 @@ - [ ] 테스트 독립성을 보장하고, 데이터 격리를 유지한다. ### 4.3 공통 규칙 + - 테스트명/메서드명은 자연어에 가깝게 시나리오를 드러낸다(한글/영문 허용). - 반복 셋업은 유틸/추상 베이스 클래스로 추출(예: `IntegrationTestUtils`). - 민감정보(비밀번호, 토큰 원문 등)는 로그에 남기지 않는다. @@ -129,6 +143,7 @@ --- ## 5. API 스펙 연동 + - 각 API 문서의 "테스트" 체크박스를 충족하는 테스트를 작성/유지한다. - 로그인: `docs/specs/api/login.md` - 회원가입: `docs/specs/api/member_register.md` @@ -139,6 +154,7 @@ --- ## 6. 실행 방법과 성능 + - 표준 실행: `./gradlew test` - 통합 테스트의 컨텍스트 초기화 비용이 크므로, 로컬 개발 중에는 대상 패키지/클래스만 선별 실행을 권장. - 빠른 피드백: 도메인/유틸 단위 테스트 우선 실행 → 이후 통합 테스트. @@ -146,12 +162,14 @@ --- ## 7. CI 연동 + - GitHub Actions 등 CI 정의는 현재 저장소에서 확인 불가. - 향후 CI 도입 시, 최소 요구: `./gradlew clean test` + ArchUnit + SpotBugs. --- ## 8. 디렉터리/네이밍 가이드 + - 테스트 루트: `src/test/java` - 관례: - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` @@ -164,6 +182,7 @@ --- ## 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/**` diff --git a/docs/todo.md b/docs/todo.md index 408b7d3..cd8330d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,16 +1,19 @@ - 2025.08.25 + - [x] `올바른_요청을_보내면_status가_SUCCESS이다` 테스트의 거짓 양성 문제 해결 필요? -> 그것보단 테스트 케이스의 부실한 부분을 수정하는게 맞는듯 - - 올바르다의 기준이 명확할 필요가 있음 + - 올바르다의 기준이 명확할 필요가 있음 - [x] `TestResult`에 인증을 위한 토큰 입력부를 추가할 필요가 있음 2025.08.26 + - [x] `TestResult`에 인증을 위한 토큰 입력부 추가 2025.08.27 + - [x] 테스트 케이스 충족 2025.08.28 + - [x] 없는 엔드포인트에 대한 처리 어찌할지 고민 - [x] 리펙터링 @@ -20,9 +23,9 @@ 2025.09.09 -- [ ] 모듈화 설계 - - [ ] public 떡칠하지 말고 기본 접근제어자 적극 활용 - - [ ] Spring Modulith 사용 가능한지 점검 +- [x] 모듈화 설계 + - [x] public 떡칠하지 말고 기본 접근제어자 적극 활용 + - [x] Spring Modulith 사용 가능한지 점검 --- diff --git a/src/main/resources/application-prod.yml b/domain/.gitignore similarity index 100% rename from src/main/resources/application-prod.yml rename to domain/.gitignore diff --git a/domain/build.gradle b/domain/build.gradle new file mode 100644 index 0000000..83e1f51 --- /dev/null +++ b/domain/build.gradle @@ -0,0 +1,25 @@ +bootJar { + enabled = false +} + +jar { + enabled = true +} + +dependencies { + implementation project(':common') + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'org.springframework.boot:spring-boot-starter-validation' + + // ---- Querydsl ---- + api 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' +} + +dependencyManagement { + imports { + mavenBom 'org.springframework.modulith:spring-modulith-bom:1.4.3' + } +} diff --git a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java b/domain/src/main/java/org/mandarin/booking/domain/AbstractEntity.java similarity index 55% rename from src/main/java/org/mandarin/booking/domain/AbstractEntity.java rename to domain/src/main/java/org/mandarin/booking/domain/AbstractEntity.java index 50c428e..dae4e25 100644 --- a/src/main/java/org/mandarin/booking/domain/AbstractEntity.java +++ b/domain/src/main/java/org/mandarin/booking/domain/AbstractEntity.java @@ -2,6 +2,7 @@ import static jakarta.persistence.GenerationType.IDENTITY; +import jakarta.annotation.Nullable; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; @@ -9,25 +10,35 @@ import lombok.Getter; import lombok.ToString; import org.hibernate.proxy.HibernateProxy; -import org.jspecify.annotations.Nullable; +import org.jspecify.annotations.NullUnmarked; +@NullUnmarked @MappedSuperclass @ToString(callSuper = true) public abstract class AbstractEntity { @Id - @Nullable - @Getter + @Getter(onMethod_ = {@Nullable}) @GeneratedValue(strategy = IDENTITY) private Long id; @Override public final boolean equals(Object o) { - if (this == o) return true; - if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); - if (thisEffectiveClass != oEffectiveClass) return false; + if (this == o) { + return true; + } + if (o == null) { + return false; + } + Class oEffectiveClass = + o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer() + .getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) { + return false; + } AbstractEntity that = (AbstractEntity) o; return getId() != null && Objects.equals(getId(), that.getId()); } diff --git a/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/EnumRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java diff --git a/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java similarity index 94% rename from src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java rename to domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java index 14b72fa..06e98aa 100644 --- a/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java @@ -2,7 +2,10 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; +import org.jspecify.annotations.NullUnmarked; + +@NullUnmarked public class EnumRequestValidator implements ConstraintValidator { private Class> clazz; private String message; diff --git a/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java b/domain/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/member/AuthRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/member/AuthRequest.java diff --git a/src/main/java/org/mandarin/booking/domain/member/Member.java b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java similarity index 83% rename from src/main/java/org/mandarin/booking/domain/member/Member.java rename to domain/src/main/java/org/mandarin/booking/domain/member/Member.java index 11aa500..ec526a8 100644 --- a/src/main/java/org/mandarin/booking/domain/member/Member.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java @@ -6,6 +6,7 @@ import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; +import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.domain.AbstractEntity; @Entity @@ -24,6 +25,16 @@ public class Member extends AbstractEntity { @Convert(converter = MemberAuthorityConverter.class) private List authorities = new ArrayList<>(); + public String[] getParsedAuthorities() { + return authorities.stream() + .map(MemberAuthority::getAuthority) + .toArray(String[]::new); + } + + public boolean matchesPassword(String rawPassword, SecurePasswordEncoder securePasswordEncoder) { + return securePasswordEncoder.matches(rawPassword, this.passwordHash); + } + public static Member create(MemberCreateCommand command, SecurePasswordEncoder securePasswordEncoder) { var member = new Member(); @@ -35,10 +46,6 @@ public static Member create(MemberCreateCommand command, return member; } - public boolean matchesPassword(String rawPassword, SecurePasswordEncoder securePasswordEncoder) { - return securePasswordEncoder.matches(rawPassword, this.passwordHash); - } - - public record MemberCreateCommand(String nickName, String userId, String password, String email){ + public record MemberCreateCommand(String nickName, String userId, String password, String email) { } } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java b/domain/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java similarity index 94% rename from src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java rename to domain/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java index 2e46be4..05d9c9c 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/MemberAuthorityConverter.java @@ -6,8 +6,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; +import org.mandarin.booking.MemberAuthority; @Converter public class MemberAuthorityConverter implements AttributeConverter, String> { @@ -20,7 +20,6 @@ public String convertToDatabaseColumn(List attribute) { return ""; } return attribute.stream() - .filter(Objects::nonNull) .map(MemberAuthority::getAuthority) .collect(Collectors.joining(DELIM)); } diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberException.java b/domain/src/main/java/org/mandarin/booking/domain/member/MemberException.java similarity index 77% rename from src/main/java/org/mandarin/booking/domain/member/MemberException.java rename to domain/src/main/java/org/mandarin/booking/domain/member/MemberException.java index 1fa3cf0..e629606 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberException.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/MemberException.java @@ -1,6 +1,6 @@ package org.mandarin.booking.domain.member; -import org.mandarin.booking.domain.DomainException; +import org.mandarin.booking.DomainException; public class MemberException extends DomainException { public MemberException(String message) { diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java similarity index 90% rename from src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java index e52d587..9e90646 100644 --- a/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/MemberRegisterRequest.java @@ -2,7 +2,9 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.NullUnmarked; +@NullUnmarked public record MemberRegisterRequest( @NotBlank(message = "Nickname cannot be blank") String nickName, diff --git a/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java b/domain/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java rename to domain/src/main/java/org/mandarin/booking/domain/member/MemberRegisterResponse.java diff --git a/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java b/domain/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/member/ReissueRequest.java diff --git a/src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java b/domain/src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java similarity index 67% rename from src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java rename to domain/src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java index fd09987..e557897 100644 --- a/src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/SecurePasswordEncoder.java @@ -1,11 +1,10 @@ package org.mandarin.booking.domain.member; +import org.jspecify.annotations.NullUnmarked; + +@NullUnmarked public interface SecurePasswordEncoder { - /** - * Encodes the given password. - * @param password - * @return - */ String encode(String password); + boolean matches(String rawPassword, String encodedPassword); } diff --git a/src/main/java/org/mandarin/booking/domain/member/package-info.java b/domain/src/main/java/org/mandarin/booking/domain/member/package-info.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/member/package-info.java rename to domain/src/main/java/org/mandarin/booking/domain/member/package-info.java diff --git a/src/main/java/org/mandarin/booking/domain/package-info.java b/domain/src/main/java/org/mandarin/booking/domain/package-info.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/package-info.java rename to domain/src/main/java/org/mandarin/booking/domain/package-info.java diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java similarity index 99% rename from src/main/java/org/mandarin/booking/domain/show/Show.java rename to domain/src/main/java/org/mandarin/booking/domain/show/Show.java index 4c2997d..d5970a0 100644 --- a/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -22,25 +22,18 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Show extends AbstractEntity { + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) + private final List schedules = new ArrayList<>(); private String title; - @Enumerated(EnumType.STRING) private Type type; - @Enumerated(EnumType.STRING) private Rating rating; - private String synopsis; - private String posterUrl; - private LocalDate performanceStartDate; - private LocalDate performanceEndDate; - @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) - private final List schedules = new ArrayList<>(); - private Show(String title, Type type, Rating rating, String synopsis, String posterUrl, LocalDate performanceStartDate, LocalDate performanceEndDate) { @@ -53,6 +46,14 @@ private Show(String title, Type type, Rating rating, String synopsis, String pos this.performanceEndDate = performanceEndDate; } + public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { + if (!isInSchedule(command.startAt(), command.endAt())) { + throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); + } + + var schedule = ShowSchedule.create(this, hallId, command); + this.schedules.add(schedule); + } public static Show create(ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); @@ -73,15 +74,6 @@ public static Show create(ShowCreateCommand command) { ); } - public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { - if (!isInSchedule(command.startAt(), command.endAt())) { - throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); - } - - var schedule = ShowSchedule.create(this, hallId, command); - this.schedules.add(schedule); - } - private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { return scheduleStartAt.isAfter(performanceStartDate.atStartOfDay()) && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowException.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowException.java similarity index 83% rename from src/main/java/org/mandarin/booking/domain/show/ShowException.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowException.java index a562fae..47165e9 100644 --- a/src/main/java/org/mandarin/booking/domain/show/ShowException.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowException.java @@ -1,6 +1,6 @@ package org.mandarin.booking.domain.show; -import org.mandarin.booking.domain.DomainException; +import org.mandarin.booking.DomainException; public class ShowException extends DomainException { public ShowException(String message) { diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleCreateCommand.java diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java rename to domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterResponse.java diff --git a/src/main/java/org/mandarin/booking/domain/show/package-info.java b/domain/src/main/java/org/mandarin/booking/domain/show/package-info.java similarity index 100% rename from src/main/java/org/mandarin/booking/domain/show/package-info.java rename to domain/src/main/java/org/mandarin/booking/domain/show/package-info.java diff --git a/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java similarity index 65% rename from src/main/java/org/mandarin/booking/domain/venue/Hall.java rename to domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java index 6411e01..6d508c4 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -11,11 +11,6 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Hall extends AbstractEntity { -// public boolean canScheduleOn(LocalDateTime startAt, LocalDateTime endAt) { -// return showSchedules.stream() -// .noneMatch(schedule -> schedule.isConflict(startAt, endAt)); -// } - public static Hall create() { return new Hall(); } diff --git a/src/main/java/org/mandarin/booking/domain/venue/HallException.java b/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java similarity index 79% rename from src/main/java/org/mandarin/booking/domain/venue/HallException.java rename to domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java index e65ebcb..62dd466 100644 --- a/src/main/java/org/mandarin/booking/domain/venue/HallException.java +++ b/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java @@ -1,6 +1,6 @@ package org.mandarin.booking.domain.venue; -import org.mandarin.booking.domain.DomainException; +import org.mandarin.booking.DomainException; public class HallException extends DomainException { public HallException(String status, String message) { diff --git a/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java b/domain/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java similarity index 97% rename from src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java rename to domain/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java index 55ede8a..bc0a707 100644 --- a/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java +++ b/domain/src/test/java/org/mandarin/booking/domain/AbstractEntityTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.io.Serial; import java.lang.reflect.Field; import java.util.HashSet; import java.util.Set; @@ -11,8 +12,16 @@ class AbstractEntityTest { - static class Member extends AbstractEntity { } - static class Product extends AbstractEntity { } + private static T withId(T entity, Long id) { + try { + Field f = AbstractEntity.class.getDeclaredField("id"); + f.setAccessible(true); + f.set(entity, id); + return entity; + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } @Nested @DisplayName("proxy branches") @@ -43,51 +52,6 @@ void hashCode_proxy_branch() { } } - // ---- HibernateProxy test doubles to cover proxy branches ---- - static class ProxyMember extends Member implements org.hibernate.proxy.HibernateProxy { - @Override - public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { - Class persistentClass = Member.class; - return (org.hibernate.proxy.LazyInitializer) java.lang.reflect.Proxy.newProxyInstance( - getClass().getClassLoader(), - new Class[]{org.hibernate.proxy.LazyInitializer.class}, - (proxy, method, args) -> { - if (method.getName().equals("getPersistentClass")) { - return persistentClass; - } - if (method.getReturnType().isPrimitive()) { - if (method.getReturnType() == boolean.class) { - return false; - } - if (method.getReturnType() == int.class) { - return 0; - } - if (method.getReturnType() == long.class) { - return 0L; - } - } - return null; - } - ); - } - - @Override - public Object writeReplace() { - return this; - } - } - - private static T withId(T entity, Long id) { - try { - Field f = AbstractEntity.class.getDeclaredField("id"); - f.setAccessible(true); - f.set(entity, id); - return entity; - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - @Nested @DisplayName("equals") class EqualsSpec { @@ -189,6 +153,47 @@ void hashCode_Collection() { } } + static class Member extends AbstractEntity { + } + + static class Product extends AbstractEntity { + } + + // ---- HibernateProxy test doubles to cover proxy branches ---- + static class ProxyMember extends Member implements org.hibernate.proxy.HibernateProxy { + @Override + public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { + Class persistentClass = Member.class; + return (org.hibernate.proxy.LazyInitializer) java.lang.reflect.Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{org.hibernate.proxy.LazyInitializer.class}, + (proxy, method, args) -> { + if (method.getName().equals("getPersistentClass")) { + return persistentClass; + } + if (method.getReturnType().isPrimitive()) { + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + } + return null; + } + ); + } + + @Serial + @Override + public Object writeReplace() { + return this; + } + } + static class ProxyProduct extends Product implements org.hibernate.proxy.HibernateProxy { @Override public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { @@ -216,6 +221,7 @@ public org.hibernate.proxy.LazyInitializer getHibernateLazyInitializer() { ); } + @Serial @Override public Object writeReplace() { return this; diff --git a/src/test/java/org/mandarin/booking/domain/MemberTest.java b/domain/src/test/java/org/mandarin/booking/domain/member/MemberTest.java similarity index 84% rename from src/test/java/org/mandarin/booking/domain/MemberTest.java rename to domain/src/test/java/org/mandarin/booking/domain/member/MemberTest.java index d9e03fe..0d3c806 100644 --- a/src/test/java/org/mandarin/booking/domain/MemberTest.java +++ b/domain/src/test/java/org/mandarin/booking/domain/member/MemberTest.java @@ -1,11 +1,9 @@ -package org.mandarin.booking.domain; +package org.mandarin.booking.domain.member; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import org.junit.jupiter.api.Test; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; class MemberTest { diff --git a/external/build.gradle b/external/build.gradle new file mode 100644 index 0000000..cddb303 --- /dev/null +++ b/external/build.gradle @@ -0,0 +1,15 @@ +bootJar { + enabled = false +} + +jar { + enabled = true +} + +dependencies { + // ---- Data & Database ---- + implementation 'com.mysql:mysql-connector-j:8.3.0' + runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'com.h2database:h2' + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' +} diff --git a/internal/build.gradle b/internal/build.gradle new file mode 100644 index 0000000..2eff4bb --- /dev/null +++ b/internal/build.gradle @@ -0,0 +1,22 @@ +bootJar { + enabled = false +} + +jar { + enabled = true +} + +dependencies { + implementation project(':common') + + // ---- Spring Boot Web ---- + api 'org.springframework.boot:spring-boot-starter-web' + + // ---- Security & Auth ---- + api 'org.springframework.boot:spring-boot-starter-security' + api 'org.springframework.security:spring-security-test' + api 'io.jsonwebtoken:jjwt-api:0.12.6' + api 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'org.springframework.boot:spring-boot-starter-aop' +} diff --git a/src/main/java/org/mandarin/booking/app/Log.java b/internal/src/main/java/org/mandarin/booking/Log.java similarity index 90% rename from src/main/java/org/mandarin/booking/app/Log.java rename to internal/src/main/java/org/mandarin/booking/Log.java index 39e7990..d9f4d84 100644 --- a/src/main/java/org/mandarin/booking/app/Log.java +++ b/internal/src/main/java/org/mandarin/booking/Log.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app; +package org.mandarin.booking; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/internal/src/main/java/org/mandarin/booking/LoggingAspect.java b/internal/src/main/java/org/mandarin/booking/LoggingAspect.java new file mode 100644 index 0000000..99e01f6 --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/LoggingAspect.java @@ -0,0 +1,118 @@ +package org.mandarin.booking; + +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.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +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 +class LoggingAspect { + + @Around("@within(org.mandarin.booking.Log) || @annotation(org.mandarin.booking.Log)") + public Object around(ProceedingJoinPoint jp) throws Throwable { + Logger logger = selectTargetLogger(jp); + String level = resolveScope(jp); + + LocalDateTime startAt = LocalDateTime.now(); + long startNs = System.nanoTime(); + String sig = jp.getSignature().toLongString(); + + logAtLevel(logger, level, "START {} at {}", sig, formatTime(startAt)); + Object ret = jp.proceed(); + long elapsedMs = nanosToMillis(System.nanoTime() - startNs); + LocalDateTime endAt = LocalDateTime.now(); + logAtLevel(logger, level, "END {} at {} ({} ms) status={}", sig, formatTime(endAt), elapsedMs, "SUCCESS"); + return ret; + } + + @AfterThrowing( + argNames = "pjp,ex", + pointcut = "@within(org.mandarin.booking.Log) || @annotation(org.mandarin.booking.Log)", + throwing = "ex" + ) + public void afterThrowing(JoinPoint pjp, Throwable ex) { + Logger logger = selectTargetLogger(pjp); + String sig = pjp.getSignature().toLongString(); + if (logger.isErrorEnabled()) { + logger.error("END {} status=FAIL cause={}", sig, ex.toString()); + } + } + + private Logger selectTargetLogger(JoinPoint joinPoint) { + Class targetClass = getTargetClass(joinPoint.getTarget()); + return LoggerFactory.getLogger(targetClass); + } + + private String resolveScope(ProceedingJoinPoint joinPoint) { + MethodSignature ms = (MethodSignature) joinPoint.getSignature(); + Method method = ms.getMethod(); + Class targetClass = getTargetClass(joinPoint.getTarget()); + 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); + } + } + default -> { + if (logger.isInfoEnabled()) { + logger.info(message, args); + } + } + } + } + + private static Class getTargetClass(Object target) { + return target.getClass(); + } + + 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/adapter/webapi/ApiResponse.java b/internal/src/main/java/org/mandarin/booking/adapter/ApiResponse.java similarity index 73% rename from src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java rename to internal/src/main/java/org/mandarin/booking/adapter/ApiResponse.java index bcf8995..7e0dbd2 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ApiResponse.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/ApiResponse.java @@ -1,11 +1,13 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; +import org.jspecify.annotations.NullUnmarked; @Getter @NoArgsConstructor +@NullUnmarked public abstract class ApiResponse { private final LocalDateTime timestamp = LocalDateTime.now(); protected ApiStatus status; diff --git a/internal/src/main/java/org/mandarin/booking/adapter/ApiStatus.java b/internal/src/main/java/org/mandarin/booking/adapter/ApiStatus.java new file mode 100644 index 0000000..1b899ff --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/ApiStatus.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.adapter; + +public enum ApiStatus { + SUCCESS, + BAD_REQUEST, + UNAUTHORIZED, + INTERNAL_SERVER_ERROR, + FORBIDDEN, + NOT_FOUND +} diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java b/internal/src/main/java/org/mandarin/booking/adapter/CommonHttpMessageConverter.java similarity index 84% rename from src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java rename to internal/src/main/java/org/mandarin/booking/adapter/CommonHttpMessageConverter.java index 4e57897..28f70f7 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/CommonHttpMessageConverter.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/CommonHttpMessageConverter.java @@ -1,8 +1,9 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.charset.StandardCharsets; +import org.jspecify.annotations.Nullable; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; @@ -17,7 +18,7 @@ @Component @Order(Ordered.HIGHEST_PRECEDENCE) -public class CommonHttpMessageConverter extends AbstractHttpMessageConverter { +class CommonHttpMessageConverter extends AbstractHttpMessageConverter { private final ObjectMapper objectMapper; public CommonHttpMessageConverter(ObjectMapper objectMapper) { @@ -45,11 +46,8 @@ protected void writeInternal(final Object objectApiResponse, final HttpOutputMes } @Override - protected void addDefaultHeaders(HttpHeaders headers, Object objectApiResponse, MediaType contentType) { - try { - super.addDefaultHeaders(headers, objectApiResponse, contentType); - } catch (IOException e) { - throw new RuntimeException(e); - } + protected void addDefaultHeaders(HttpHeaders headers, Object objectApiResponse, @Nullable MediaType contentType) + throws IOException { + super.addDefaultHeaders(headers, objectApiResponse, contentType); } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/CustomAccessDeniedHandler.java similarity index 81% rename from src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java rename to internal/src/main/java/org/mandarin/booking/adapter/CustomAccessDeniedHandler.java index 8ec2f83..335de1b 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAccessDeniedHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/CustomAccessDeniedHandler.java @@ -1,6 +1,6 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; -import static org.mandarin.booking.adapter.webapi.ApiStatus.FORBIDDEN; +import static org.mandarin.booking.adapter.ApiStatus.FORBIDDEN; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; @@ -8,7 +8,6 @@ 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; @@ -16,7 +15,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class CustomAccessDeniedHandler implements AccessDeniedHandler { +class CustomAccessDeniedHandler implements AccessDeniedHandler { private final ObjectMapper objectMapper; @Override diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java b/internal/src/main/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPoint.java similarity index 63% rename from src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java rename to internal/src/main/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPoint.java index 83df390..40e59e8 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPoint.java @@ -1,19 +1,17 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; 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 { +class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; @Override @@ -22,16 +20,17 @@ public void commence(HttpServletRequest request, HttpServletResponse response, 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 message = getMessage(request, authException); + 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(); + private static String getMessage(HttpServletRequest request, AuthenticationException authException) { + if (request.getAttribute("exception") == null) { + return authException.getMessage(); } - return authException.getMessage(); + return request.getAttribute("exception").toString(); } + } diff --git a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java b/internal/src/main/java/org/mandarin/booking/adapter/CustomMemberAuthenticationToken.java similarity index 67% rename from src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java rename to internal/src/main/java/org/mandarin/booking/adapter/CustomMemberAuthenticationToken.java index ac1f9d8..db83926 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/CustomMemberAuthenticationToken.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/CustomMemberAuthenticationToken.java @@ -1,14 +1,18 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; import java.util.Collection; +import org.jspecify.annotations.NullUnmarked; +import org.mandarin.booking.MemberAuthority; 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); + public CustomMemberAuthenticationToken(String userId, Collection authorities) { + super(authorities.stream() + .map(authority -> (GrantedAuthority) authority::getAuthority) + .toList()); this.userId = userId; super.setAuthenticated(true); } @@ -19,6 +23,7 @@ public String getName() { } @Override + @NullUnmarked public Object getCredentials() { return null; } diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java b/internal/src/main/java/org/mandarin/booking/adapter/ErrorResponse.java similarity index 89% rename from src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java rename to internal/src/main/java/org/mandarin/booking/adapter/ErrorResponse.java index 28f7cdb..7db3cdf 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ErrorResponse.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/ErrorResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.ToString; diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java similarity index 69% rename from src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java rename to internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java index 0892a64..52f4860 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/GlobalExceptionHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java @@ -1,13 +1,13 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; import static java.util.Objects.requireNonNull; -import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.adapter.webapi.ApiStatus.NOT_FOUND; -import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import lombok.extern.slf4j.Slf4j; -import org.mandarin.booking.domain.DomainException; -import org.mandarin.booking.domain.member.AuthException; +import org.mandarin.booking.AuthException; +import org.mandarin.booking.DomainException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -15,7 +15,7 @@ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { +class GlobalExceptionHandler { @ExceptionHandler(DomainException.class) public ErrorResponse handleJsonParseError(DomainException ex) { @@ -31,8 +31,8 @@ public ErrorResponse handleAuthException(AuthException ex) { @ExceptionHandler(MethodArgumentNotValidException.class) public ErrorResponse handleValidationException(MethodArgumentNotValidException ex) { - return new ErrorResponse(BAD_REQUEST, - requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage()); + var defaultMessage = requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage(); + return new ErrorResponse(BAD_REQUEST, requireNonNull(defaultMessage)); } @ExceptionHandler(NoHandlerFoundException.class) diff --git a/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java similarity index 92% rename from src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java rename to internal/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java index c1a70af..139531d 100644 --- a/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/JacksonCustomizerConfig.java @@ -5,7 +5,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -public class JacksonCustomizerConfig { +class JacksonCustomizerConfig { @Bean Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { diff --git a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java b/internal/src/main/java/org/mandarin/booking/adapter/JwtFilter.java similarity index 91% rename from src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java rename to internal/src/main/java/org/mandarin/booking/adapter/JwtFilter.java index cc67cd3..0b7e9a4 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/JwtFilter.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -8,9 +8,9 @@ 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.jspecify.annotations.Nullable; +import org.mandarin.booking.AuthException; +import org.mandarin.booking.MemberAuthority; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -67,7 +67,7 @@ private List getAuthorities(String token) { .map(MemberAuthority::valueOf).toList(); } - private boolean isTokenBlank(String header) { + private boolean isTokenBlank(@Nullable String header) { return header == null || header.equals("Bearer"); } diff --git a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java b/internal/src/main/java/org/mandarin/booking/adapter/JwtTokenUtils.java similarity index 84% rename from src/main/java/org/mandarin/booking/app/JwtTokenUtils.java rename to internal/src/main/java/org/mandarin/booking/adapter/JwtTokenUtils.java index 4874b77..7704cc0 100644 --- a/src/main/java/org/mandarin/booking/app/JwtTokenUtils.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/JwtTokenUtils.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.adapter; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; @@ -14,12 +14,12 @@ import java.util.Map; import java.util.stream.Collectors; import javax.crypto.SecretKey; -import org.mandarin.booking.domain.member.AuthException; -import org.mandarin.booking.domain.member.MemberAuthority; -import org.mandarin.booking.domain.member.TokenHolder; +import org.jspecify.annotations.Nullable; +import org.mandarin.booking.AuthException; +import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.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 @@ -34,6 +34,7 @@ public class JwtTokenUtils implements TokenUtils { @Value("${jwt.token.refresh}") private long refreshTokenExp; + @Nullable private SecretKey key; @Autowired @@ -43,7 +44,7 @@ public void setKey(@Value("${jwt.token.secret}") String secretKey) { @Override public TokenHolder generateToken(String userId, String nickName, - Collection authorities) { + Collection authorities) { String accessToken = generateTokenInternal(userId, nickName, authorities, accessTokenExp); String refreshToken = generateTokenInternal(userId, nickName, authorities, refreshTokenExp); return new TokenHolder(accessToken, refreshToken); @@ -55,7 +56,7 @@ public TokenHolder generateToken(String refreshToken) { String userId = claims.getPayload().get(USER_ID).toString(); String nickName = claims.getPayload().get(NICK_NAME).toString(); List authorities = Arrays.stream(claims.getPayload().get(ROLES, String.class).split(",")) - .map(s->s.substring(5)) + .map(s -> s.substring(5)) .map(MemberAuthority::valueOf) .toList(); return generateToken(userId, nickName, authorities); @@ -72,13 +73,14 @@ public String getClaim(String token, String claimName) { public Collection getClaims(String token, String claimName) { Jws claims = parseClaims(token); var rawPayload = claims.getPayload().get(claimName, String.class); - if(rawPayload.isBlank()) + if (rawPayload.isBlank()) { return new ArrayList<>(); + } return Arrays.stream(rawPayload.split(",")).toList(); } private String generateTokenInternal(String userId, String nickName, - Collection authorities, long expiration) { + Collection authorities, long expiration) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date exp = new Date(nowMillis + expiration); @@ -90,7 +92,7 @@ private String generateTokenInternal(String userId, String nickName, .claims(Map.of( USER_ID, userId, NICK_NAME, nickName, - ROLES, authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")) + ROLES, authorities.stream().map(MemberAuthority::getAuthority).collect(Collectors.joining(",")) )) .issuedAt(now) .expiration(exp) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java b/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java similarity index 75% rename from src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java rename to internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java index 270fbb1..0495c3f 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ResponseWrapper.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java @@ -1,5 +1,7 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; +import java.util.Objects; +import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -10,7 +12,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; @RestControllerAdvice -public class ResponseWrapper implements ResponseBodyAdvice { +class ResponseWrapper implements ResponseBodyAdvice { @Override public boolean supports(final MethodParameter returnType, final Class> converterType) { @@ -18,10 +20,10 @@ public boolean supports(final MethodParameter returnType, } @Override - public Object beforeBodyWrite(final Object body, final MethodParameter returnType, + public Object beforeBodyWrite(@Nullable final Object body, final MethodParameter returnType, final MediaType selectedContentType, final Class> selectedConverterType, final ServerHttpRequest request, final ServerHttpResponse response) { - return new SuccessResponse<>(ApiStatus.SUCCESS, body); + return new SuccessResponse<>(ApiStatus.SUCCESS, Objects.requireNonNull(body)); } } diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java similarity index 97% rename from src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java rename to internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java index 79e5e73..a277d6f 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -1,7 +1,6 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; 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; @@ -22,7 +21,7 @@ @Configuration @RequiredArgsConstructor -public class SecurityConfig { +class SecurityConfig { private final TokenUtils tokenUtils; @Bean diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java b/internal/src/main/java/org/mandarin/booking/adapter/SuccessResponse.java similarity index 86% rename from src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java rename to internal/src/main/java/org/mandarin/booking/adapter/SuccessResponse.java index 7d7e55f..4465a0b 100644 --- a/src/main/java/org/mandarin/booking/adapter/webapi/SuccessResponse.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SuccessResponse.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.webapi; +package org.mandarin.booking.adapter; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/org/mandarin/booking/app/TokenUtils.java b/internal/src/main/java/org/mandarin/booking/adapter/TokenUtils.java similarity index 60% rename from src/main/java/org/mandarin/booking/app/TokenUtils.java rename to internal/src/main/java/org/mandarin/booking/adapter/TokenUtils.java index 31a7a3d..d9a31f1 100644 --- a/src/main/java/org/mandarin/booking/app/TokenUtils.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/TokenUtils.java @@ -1,13 +1,13 @@ -package org.mandarin.booking.app; +package org.mandarin.booking.adapter; import java.util.Collection; -import org.mandarin.booking.domain.member.TokenHolder; -import org.springframework.security.core.GrantedAuthority; +import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.TokenHolder; public interface TokenUtils { TokenHolder generateToken(String refreshToken); - TokenHolder generateToken(String userId, String nickName, Collection authorities); + TokenHolder generateToken(String userId, String nickName, Collection authorities); String getClaim(String token, String claimName); diff --git a/internal/src/main/java/org/mandarin/booking/adapter/package-info.java b/internal/src/main/java/org/mandarin/booking/adapter/package-info.java new file mode 100644 index 0000000..f54caa2 --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.mandarin.booking.adapter; + +import org.jspecify.annotations.NullMarked; diff --git a/internal/src/test/java/org/mandarin/booking/adapter/CommonHttpMessageConverterTest.java b/internal/src/test/java/org/mandarin/booking/adapter/CommonHttpMessageConverterTest.java new file mode 100644 index 0000000..d5a9f15 --- /dev/null +++ b/internal/src/test/java/org/mandarin/booking/adapter/CommonHttpMessageConverterTest.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.adapter; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpInputMessage; + +@ExtendWith(MockitoExtension.class) +class CommonHttpMessageConverterTest { + @InjectMocks + CommonHttpMessageConverter converter; + + @Test + void readInternal_throwsUnsupportedOperation() { + HttpInputMessage msg = mock(HttpInputMessage.class); + assertThrows( + UnsupportedOperationException.class, + () -> converter.readInternal(ApiResponse.class, msg) + ); + } +} diff --git a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java b/internal/src/test/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPointTest.java similarity index 98% rename from src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java rename to internal/src/test/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPointTest.java index dd81059..b17dc28 100644 --- a/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPointTest.java +++ b/internal/src/test/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPointTest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.adapter; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; diff --git a/internal/src/test/java/org/mandarin/booking/adapter/CustomMemberAuthenticationTokenTest.java b/internal/src/test/java/org/mandarin/booking/adapter/CustomMemberAuthenticationTokenTest.java new file mode 100644 index 0000000..92ec5cd --- /dev/null +++ b/internal/src/test/java/org/mandarin/booking/adapter/CustomMemberAuthenticationTokenTest.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.MemberAuthority.USER; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class CustomMemberAuthenticationTokenTest { + + @Test + void getCredentials() { + var toke = new CustomMemberAuthenticationToken("user", List.of(USER)); + assertThat(toke.getCredentials()).isNull(); + } +} diff --git a/settings.gradle b/settings.gradle index 19e1830..59439df 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,8 @@ rootProject.name = 'booking' + +include ':application' +include ':domain' + +include 'internal' +include 'common' +include 'external' \ No newline at end of file diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java b/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java deleted file mode 100644 index bba5da6..0000000 --- a/src/main/java/org/mandarin/booking/adapter/webapi/ApiStatus.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.mandarin.booking.adapter.webapi; - -/** - * Centralizes API status codes to achieve type-safety and remove string duplication. - * JSON representation remains identical via enum.name(). - */ -public enum ApiStatus { - SUCCESS, - BAD_REQUEST, - UNAUTHORIZED, - INTERNAL_SERVER_ERROR, - FORBIDDEN, - NOT_FOUND -} diff --git a/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java b/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java deleted file mode 100644 index c23a11d..0000000 --- a/src/main/java/org/mandarin/booking/app/HallExistCheckEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.Getter; - -@Getter -public class HallExistCheckEvent { - private final Long hallId; - private boolean exist = false; - - public HallExistCheckEvent(Long hallId) { - this.hallId = hallId; - } - - public void exist() { - this.exist = true; - } -} diff --git a/src/main/java/org/mandarin/booking/app/HallService.java b/src/main/java/org/mandarin/booking/app/HallService.java deleted file mode 100644 index c04a0df..0000000 --- a/src/main/java/org/mandarin/booking/app/HallService.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.mandarin.booking.app; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.HallQueryRepository; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class HallService { - private final HallQueryRepository queryRepository; - - @EventListener(HallExistCheckEvent.class) - public void hallExistCheckHandler(HallExistCheckEvent event) { - if (queryRepository.existsById(event.getHallId())) { - event.exist(); - } - } -} diff --git a/src/main/java/org/mandarin/booking/app/LoggingAspect.java b/src/main/java/org/mandarin/booking/app/LoggingAspect.java deleted file mode 100644 index c24f433..0000000 --- a/src/main/java/org/mandarin/booking/app/LoggingAspect.java +++ /dev/null @@ -1,94 +0,0 @@ -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/domain/member/MemberAuthority.java b/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java deleted file mode 100644 index 649d5f4..0000000 --- a/src/main/java/org/mandarin/booking/domain/member/MemberAuthority.java +++ /dev/null @@ -1,14 +0,0 @@ -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/MemberDetails.java b/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java deleted file mode 100644 index b302d75..0000000 --- a/src/main/java/org/mandarin/booking/domain/member/MemberDetails.java +++ /dev/null @@ -1,31 +0,0 @@ -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/test/java/org/mandarin/booking/BookingApplicationTests.java b/src/test/java/org/mandarin/booking/BookingApplicationTests.java deleted file mode 100644 index 65ed601..0000000 --- a/src/test/java/org/mandarin/booking/BookingApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.mandarin.booking; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class BookingApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/org/mandarin/booking/DocsUtils.java b/src/test/java/org/mandarin/booking/DocsUtils.java deleted file mode 100644 index c65ccbb..0000000 --- a/src/test/java/org/mandarin/booking/DocsUtils.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.mandarin.booking; - -import static io.restassured.RestAssured.given; -import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; -import static java.lang.StackWalker.getInstance; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.restassured.filter.Filter; -import io.restassured.http.ContentType; -import io.restassured.specification.RequestSpecification; -import java.lang.StackWalker.StackFrame; -import java.util.Map; -import org.springframework.core.env.Environment; -import org.springframework.restdocs.ManualRestDocumentation; -import org.springframework.restdocs.restassured.RestAssuredRestDocumentation; -import org.springframework.stereotype.Component; - -@Component -public record DocsUtils(Environment environment, - ObjectMapper objectMapper) { - private static final ManualRestDocumentation restDocumentation = new ManualRestDocumentation(); - - - private static volatile boolean started = false; - - public String execute(String method, String path, Object requestBody, Map headers) - throws Exception { - var snippet = sanitize(method, path); - boolean disableDocs = isRestDocsDisabledForCurrentCall(); - if (!disableDocs) { - ensureStarted(); - } - var spec = prepareSpec(headers, disableDocs); - if ("POST".equals(method)) { - spec.contentType(ContentType.JSON); - if (requestBody != null) { - spec.body(objectMapper.writeValueAsString(requestBody)); - } - } - var resp = ("GET".equals(method)) - ? (disableDocs ? spec.when().get(path) - : spec.filter(docFilter(snippet)).when().get(path)) - : (disableDocs ? spec.when().post(path) - : spec.filter(docFilter(snippet)).when().post(path)); - return resp.then().extract().asString(); - } - - - private String sanitize(String method, String path) { - var name = method + path; - name = name.replaceAll("^/+", ""); - name = name.replaceAll("[/{}]", "-"); - name = name.replaceAll("[^a-zA-Z0-9-_]", "-"); - return name.toLowerCase(); - } - - private boolean isRestDocsDisabledForCurrentCall() { - try { - return getInstance(RETAIN_CLASS_REFERENCE) - .walk(frames -> frames - .map(StackFrame::getDeclaringClass) - .filter(cls -> cls.getName().startsWith("org.mandarin")) - .anyMatch(cls -> cls.isAnnotationPresent(NoRestDocs.class))); - } catch (Throwable t) { - return false; - } - } - - private void ensureStarted() { - if (!started) { - synchronized (DocsUtils.class) { - if (!started) { - restDocumentation.beforeTest(DocsUtils.class, "integration-tests"); - started = true; - } - } - } - } - - private RequestSpecification withDocs(RequestSpecification spec) { - return spec.filter(RestAssuredRestDocumentation.documentationConfiguration(restDocumentation)); - } - - private Filter docFilter(String snippet) { - return document( - snippet, - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()) - ); - } - - private RequestSpecification prepareSpec(Map headers, boolean disableDocs) { - Integer port = environment.getProperty("local.server.port", Integer.class); - if (port == null) { - String p = environment.getProperty("local.server.port"); - port = (p != null) ? Integer.parseInt(p) : 0; - } - var spec = given() - .port(port) - .accept(ContentType.JSON); - if (!disableDocs) { - spec = withDocs(spec); - } - if (headers != null) { - headers.forEach(spec::header); - } - return spec; - } -} diff --git a/src/test/java/org/mandarin/booking/TestOnlyController.java b/src/test/java/org/mandarin/booking/TestOnlyController.java deleted file mode 100644 index ef14c46..0000000 --- a/src/test/java/org/mandarin/booking/TestOnlyController.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.mandarin.booking; - -import java.util.Map; -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; - -//@RestController -//@RequestMapping(path = "/test", produces = MediaType.APPLICATION_JSON_VALUE) -public record TestOnlyController(MemberQueryRepository memberQueryRepository) { - - @PostMapping(path = "/echo", consumes = MediaType.APPLICATION_JSON_VALUE) - public Map echo(@RequestBody Map body) { - return body; - } - - @PostMapping(path = "/member/exists", consumes = MediaType.APPLICATION_JSON_VALUE) - public Boolean exists(@RequestBody Map body) { - String userId = body.get("userId"); - return userId != null && memberQueryRepository.existsByUserId(userId); - } -} diff --git a/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java b/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java deleted file mode 100644 index ee8972c..0000000 --- a/src/test/java/org/mandarin/booking/app/LoggingAspectTest.java +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 4dfec37..0000000 --- a/src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java +++ /dev/null @@ -1,26 +0,0 @@ -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/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index 1f0955d..0000000 --- a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline