diff --git a/AGENTS.md b/AGENTS.md index 5611fd2..d8166f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,289 +1,147 @@ ---- -적용: 항상 ---- +# AGENTS.md — booking + +> 사람용 README 아님. **사실만**, **짧게**, **삭제 우선**. 모호/불명은 **“확인 불가”**로 표기. + +## 0) 골든룰 + +* 프롬프트 장황 금지. **지시만** 적고 설명은 최대한 생략. +* **계획/사고 지시(think harder, plan, chain-of-thought)** 금지. Agent가 알아서 함. +* **툴은 기본 `apply_patch`만 사용.** 외부 편집/파일 매니퓰레이션 툴 지양. +* 라이브러리 지시가 필요하면 **이름만**(예: “use Querydsl”). +* **사실 우선, 추측 금지.** 근거 파일이 없으면 “확인 불가”. +* 충돌 시 **`docs/specs/policy/application.md`** 우선. + +## 1) 스코프 + +* 멀티 모듈. 근거: `settings.gradle`. +* 아래의 구조를 따름 + +```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 + +``` + +## 2) 신뢰 근거(Sources of Truth) + +* 정책 최상위: `docs/specs/policy/application.md` +* 도메인 개요: `docs/specs/domain.md` +* API: `docs/specs/api/*.md` +* 테스트 정책: `docs/specs/policy/test.md` +* 빌드/도구: `build.gradle`, `gradle/wrapper/*` +* 설정: `application/src/main/resources/application*.yml` -Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) - -# AGENTS.md — 에이전트를 위한 실행 지침서 (booking) - -본 문서는 사람용 README가 아닌, 코드 에이전트(코딩 봇)를 위한 실행 지침서입니다. 이 저장소의 실제 파일과 문서를 스캔해 확인된 사실만 기술합니다. 확인 불가능한 항목은 "확인 불가"로 명시합니다. - -## 1) Title & Scope - -- 목적: 이 파일은 에이전트를 위한 지침서입니다. 개발/운영 작업 자동화 시 준수 규칙과 명령을 제공합니다. -- 적용 범위: 단일 레포지토리(single repo), 단일 모듈 구조 - - 근거: `settings.gradle`에 `rootProject.name = 'booking'`만 정의되어 있으며 서브프로젝트 선언 없음. - -## 2) Documentation Reference (docs 우선) - -- 모든 작업 전/중에 `./docs` 디렉토리 문서를 우선적으로 참조하십시오. -- 주요 문서: - - 아키텍처 규칙: `docs/specs/policy/application.md` - - 도메인 개요: `docs/specs/domain.md` - - API 명세: `docs/specs/api/*.md` - - 인증/인가 정책 문서 틀: `docs/specs/policy/authentication.md`, `docs/specs/policy/authorization.md` (현재 비어 있음) - - 작업 메모/할 일: `docs/devlog/*`, `docs/todo.md` -- 세부 설계나 정책 충돌 시: `docs/specs/policy/application.md`의 규칙을 최우선으로 따르십시오. - -## 3) Project Setup - -- JDK/Gradle/Spring Boot 버전 - - Java: 21 (근거: `build.gradle` → `java.toolchain.languageVersion = 21`) - - Gradle Wrapper: 8.14.3 (근거: `gradle/wrapper/gradle-wrapper.properties` → `distributionUrl`) - - Spring Boot: 3.5.4 (근거: `build.gradle` → `id 'org.springframework.boot' version '3.5.4'`) -- 빌드/의존성 설치 - - 명령: `./gradlew clean build` - - 테스트는 JUnit Platform 사용, 테스트 프로필은 Gradle test 태스크에서 `spring.profiles.active=test`로 설정됨 (근거: `build.gradle` → - tasks.named('test')). -- 로컬 실행 - - 명령: `./gradlew bootRun` - - 활성 프로필: 기본 `local` (근거: `src/main/resources/application.yml` → `spring.profiles.active: local`) - - DB 및 JWT 설정은 `application-local.yml` 참고. 민감정보는 환경변수로 덮어쓰기를 권장. -- 테스트 실행 - - 기본: `./gradlew test` (JUnit5, Spring Boot Test, Mockito, Security Test, ArchUnit 포함) - - 테스트 시 프로필: `test` (근거: `build.gradle` test 태스크 설정) -- 코드 분석/품질 도구 - - SpotBugs 사용 (근거: `build.gradle` → `com.github.spotbugs` 플러그인). 일반 태스크: `spotbugsMain`, `spotbugsTest`. - - 포매터/린터(Spotless/Checkstyle 등): 확인 불가 (저장소 내 설정/플러그인 없음). - -## 4) Architecture Rules (준수 규칙) - -- 전반: 헥사고날 아키텍처(Ports & Adapters) - - 근거: `docs/specs/policy/application.md` 및 `src/test/java/.../arch/HexagonalArchitectureTest.java` -- 계층 및 패키지 - - domain: `org.mandarin.booking.domain` — 순수 모델/예외/커맨드/요청/응답 (프레임워크 의존 금지) - - app: `org.mandarin.booking.app` — 유스케이스 서비스, 포트(`app/port`), 퍼시스턴스 포트/구현(`app/persist`), AOP 등 - - adapter: `org.mandarin.booking.adapter` — webapi, security 등 외부와의 접점 - - 아키텍처 테스트 강제: `HexagonalArchitectureTest` 층 규칙 참조. -- DDD/엔티티 규칙(요지) - - Aggregate Root 공개 범위 준수, 도메인 엔티티는 domain 패키지에 위치. - - DTO(요청/응답/커맨드)는 현재 domain에 위치하며 컨트롤러에서 변환하여 사용 (근거: policy 문서). -- 영속성(JPA) - - 의존성: `spring-boot-starter-data-jpa`, DB: MySQL(H2 for test), P6Spy (근거: `build.gradle`) - - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `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` (구체 로직은 소스 참조). -- 메시징/캐시/검색 - - Kafka/Redis/Elasticsearch 사용: 확인 불가 (관련 의존성/설정 없음). - -자세한 규칙은 `docs/specs/policy/application.md`를 준수하십시오. - -## 5) Code Style & Readability Rules (읽기 좋은 코드 작성 규칙) - -에이전트가 코드를 작성할 때 반드시 준수해야 하는 가독성 중심 규칙입니다. - -5.1 네이밍 - -- 클래스/인터페이스: 명확한 도메인 언어 사용 (예: ShowRegisterer, PaymentGatewayClient) -- 메서드: 동사+목적어 (예: registerShow, validateToken, findMemberByEmail) -- 변수/필드: 축약 금지, 문맥 명확히 (예: memberEmail, jwtTokenSecret) -- 테스트 메서드: 시나리오 기반 네이밍 (예: shouldFailWhenPasswordIsInvalid) - -5.2 OOP 원칙 - -- Tell, don’t ask: 데이터를 꺼내 연산하지 말고 객체에게 메시지를 보내라. -- SRP: 클래스는 한 가지 책임만. 메서드는 5~10줄 이내 유지. -- 불변 객체 지향: 값 객체(Value Object)는 final 필드와 팩토리 메서드 사용. -- 의미 있는 도메인 모델: Map 대신 MemberProfile 같은 타입을 정의. - -5.3 글 읽듯이 읽히는 코드 - -- 한 메서드는 하나의 이야기(스토리)를 표현해야 함. -- 중첩 if/else 최소화 → 조기 반환(early return) 활용. -- 숫자/문자열 리터럴은 상수화 (MAX_RETRY_COUNT, DATE_FORMAT_PATTERN). -- 체이닝 시 가독성 유지: 각 메서드 호출을 줄바꿈 정렬. - -5.4 주석 규칙 - -- 기본 원칙: 코드 자체가 의도를 설명할 수 있도록 작성 → 불필요한 주석 금지. -- 허용되는 주석 위치/용도 -- 인터페이스/추상 클래스: 계약(Contract) 설명 -- 복잡한 알고리즘/비즈니스 규칙: 수학적 공식, 근거 논문, 외부 레퍼런스 출처 표기 -- TODO/FIXME: 보완 필요시 명확한 설명과 함께 작성 -- 금지: “어떻게 동작하는지”를 코드 그대로 설명하는 주석. - -작업 시 반드시 `docs/specs/policy/application.md` 규칙을 따르십시오. - -## 6) Commands (신뢰 가능한 명령만) - -- Gradle Wrapper 사용을 강제합니다. +## 3) 빌드/실행 ```bash -# 빌드 -./gradlew clean build +./gradlew clean build # 빌드 +./gradlew test # 테스트 (JUnit Platform) +./gradlew bootRun # 로컬 실행 +``` -# 단위/통합 테스트 (test 프로필로 실행) -./gradlew test +* Java 21, Gradle Wrapper 8.14.3, Spring Boot 3.5.4 (근거: `build.gradle`, wrapper). +* 기본 프로필: `local` (`application.yml`). -# SpotBugs 정적 분석 -./gradlew spotbugsMain spotbugsTest +## 4) 아키텍처 규칙(요지) -# 로컬 실행 (기본 활성 프로필: local) -./gradlew bootRun +* **헥사고날**: adapter → app(ports/services/persist) → domain. +* domain: 프레임워크 비의존. app만 의존 가능. +* Spring Data JPA 리포지토리 위치: `app/persist` (근거: 코드/문서 일치). +* 아키텍처 테스트: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java`. -# Docker로 MySQL 기동 (compose.yaml 기반) -docker compose up -d +## 5) 보안(요지) -# 통합 테스트만 별도 태깅 없음 → 전체 테스트 수행 -./gradlew test -``` +* Spring Security + JWT. `internal/src/main/java/org/mandarin/booking/adapter/*`(SecurityConfig/JwtFilter/Handlers), + `application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java` + 참고. -근거 파일/경로: - -- `build.gradle` (plugins, dependencies, test 태스크) -- `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` -- `compose.yaml` - -## 7) Contribution & PR Rules - -- 브랜치/커밋/PR 규약: `.github/pull_request_template.md` 참조 -- 권장 체크리스트(제안): - - [ ] 모든 테스트 통과 (`./gradlew test`) - - [ ] SpotBugs 통과 (`./gradlew spotbugsMain spotbugsTest`) - - [ ] 아키텍처 테스트 통과 (`HexagonalArchitectureTest`) - - [ ] docs/specs/* 업데이트 반영 -- 코드 스타일 점검 - - [ ] 코드가 글 읽듯 자연스럽게 읽히는가? (네이밍, 메서드 길이, 역할 분리 확인) - - [ ] 불필요한 주석이 없는가? 필요한 주석만 인터페이스/복잡 규칙에 존재하는가? - - [ ] 모든 테스트/SpotBugs/아키텍처 테스트 통과 여부 - -## 8) Environments - -- 프로필 - - local: 기본 활성 (근거: `application.yml`), MySQL 설정 및 JWT 시크릿 포함 (근거: `application-local.yml`). - - test: H2 메모리 DB, JPA DDL auto create, JWT 설정 포함 (근거: `application-test.yml`). - - prod: 파일 존재하나 내용 비어 있음 → 확인 불가. -- 필수 환경변수(권장 키) - - `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD` - - `JWT_TOKEN_SECRET`, `JWT_TOKEN_ACCESS`, `JWT_TOKEN_REFRESH` - - 위 값은 실제 비밀을 포함하므로, 절대 저장소에 커밋하지 말고 실행 환경에서 주입하십시오. 현재 로컬 yml에는 예시 값이 존재하나, 운영에서는 환경변수로 덮어쓰기를 권장. - -## 9) Limitations / Unknowns - -- CI/CD(.github/workflows) 설정: 확인 불가. -- 코드 포매팅(Spotless/Checkstyle): 구성 없음 → 확인 불가. -- 마이그레이션 도구(Flyway/Liquibase): 확인 불가. -- 메시징(Kafka)/캐시(Redis)/검색(Elasticsearch): 사용 근거 없음 → 확인 불가. -- 배포(AWS/K8s): 설정/스크립트 부재 → 확인 불가. - -향후 TODO(사람이 보완): - -- prod 프로필 구성 및 비밀 주입 전략 정의 -- CI 파이프라인 도입(.github/workflows) -- DB 마이그레이션 도구 채택 및 규약 수립 -- 인증/인가 정책 문서(`authentication.md`, `authorization.md`) 구체화 -- 코드 포매터 도입 여부 결정(Spotless/Checkstyle 등) - -## 10) Appendix - -- 모듈/계층 의존 개요(텍스트) - - adapter(webapi, security) → app\(ports, services, persist adapters) → domain - - domain은 프레임워크 비의존, app은 domain에만 의존, adapter는 app의 포트에 의존 -- 주요 디렉터리 - - `src/main/java/org/mandarin/booking/BookingApplication.java` — Spring Boot 엔트리포인트 - - `src/main/java/org/mandarin/booking/adapter/webapi/*` — REST 컨트롤러/응답 래핑 - - `src/main/java/org/mandarin/booking/adapter/security/*` — 보안 구성/JWT 필터/프로바이더 - - `src/main/java/org/mandarin/booking/app/*` — 서비스, AOP, 포트, 퍼시스트 어댑터 - - `src/main/java/org/mandarin/booking/domain/*` — 도메인 모델과 DTO/커맨드 - - `src/test/java/...` — 단위/아키텍처/통합 테스트 스위트 - -## 11) Test - -- TDD 원칙: 새로운 기능은 테스트부터 작성하고, `./gradlew test`로 검증하십시오. -- 테스트 정책 근거: - - JUnit Platform 활성화 및 test 프로필 지정 (근거: `build.gradle` test 태스크) - - 아키텍처 검증: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` - - Web/API 및 보안 단위/통합 테스트 예시: `src/test/java/org/mandarin/booking/webapi/**`, `adapter/security/**` -- 단위 vs 통합 테스트 - - 단위: 도메인/서비스 단위 로직 검증, 외부 의존 모킹(Mock) (예: Mockito) - - 통합: Spring Context 기동, 필터/시큐리티/컨트롤러 경로 포함. 테스트 프로필 `test`와 H2 DB 사용. -- 자세한 내용은 `./docs/specs/policy/test.md`를 참조. +## 6) 테스트 ---- +* 단위/통합 테스트는 JUnit5, H2(test). 자세한 기준은 `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 스펙 문서 템플릿 - 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` - -- 정책 문서 템플릿 - - 번호 있는 대제목(1., 2., 3. …)을 사용하여 규칙을 체계화 - - 레이어/의존/포트/보안/테스트 등 주제별 세부 항목을 불릿으로 상세화 - - 실제 코드/설정 파일 경로를 근거로 명시 - - "확인 불가"를 명확히 표기해 향후 TODO를 남김 - - 근거 예시: `docs/specs/policy/application.md`, `docs/specs/policy/test.md` - -- 링크/근거 표기 규칙 - - 저장소 상대 경로 링크를 사용합니다(예: `build.gradle`, `src/main/...`). - - 문서 말미 또는 섹션 말미에 "근거" 목록을 배치하여 신뢰 가능한 출처를 나열합니다. - - 정책/가이드 문서에는 규칙 옆에 괄호로 근거를 병기해도 됩니다. - -- 표기/형식 규칙 - - 헤더 레벨은 H2(##)부터 사용해 문서 내 구조를 안정적으로 유지합니다. - - 코드/JSON/명령은 fenced code block 사용. JSON 예시에는 실제 키를 포함하되 민감정보는 예시 값으로 대체. - - 에러/예외/상태코드는 명시적으로 표기(예: `400 Bad Request`, `401 Unauthorized`). - - 체크박스는 `[x]`(충족) / `[ ]`(미충족) 형식으로 유지. - -- 테스트와의 동기화 - - API 문서의 테스트 체크리스트는 실제 테스트(`src/test/java/...`)와 1:1로 대응하도록 작성/갱신합니다. - - 새로운 테스트가 추가되면 해당 API 문서의 체크리스트도 즉시 업데이트합니다. - - 근거: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java`, `adapter/security/*Test.java`, - `arch/HexagonalArchitectureTest.java` - -- 프로필/환경 기술 시 유의사항 - - 실제 yml의 키/값/프로필을 그대로 반영하고, 운영 비밀은 "환경변수로 주입"이라고만 적습니다. - - 근거: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` - -- 금지 사항 - - 추측성 서술 금지("추정/아마도" 금지). 알 수 없는 항목은 "확인 불가". - - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. - -- 예시 스니펫 스타일 - - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/show_register.md`의 curl 예시) - - JSON: 축약하지 말고 필요한 필드를 명시, 포맷은 pretty 또는 단일 라인 일관 유지. +## 7) 환경/프로필 ---- +* `local`, `test` 활성. `prod` 내용 비어 있음 → **확인 불가**. +* 민감정보는 **환경변수로 주입**. 예: `SPRING_DATASOURCE_*`, `JWT_TOKEN_*`. + +## 8) 알려진 제한/불명 + +* CI/CD, 포매터(Spotless/Checkstyle), 마이그레이션(Flyway/Liquibase), Kafka/Redis/ES, 배포 스크립트: **확인 불가**. -근거 스니펫 링크/파일 경로 요약: +## 9) PR 체크리스트(필수) + +* [ ] `./gradlew test` 통과 +* [ ] 아키텍처 테스트 통과 +* [ ] 문서 최신화(`docs/specs/*`) + +## 10) 금지/지양 + +* 장문 프롬프트, 메타지시(“더 깊게 생각해”) 추가 금지. +* apply_patch 외 임의 파일 편집 툴 지양. +* 추측성 기술 금지. 불명은 **“확인 불가”**. + +## 11) `apply_patch` 사용 예 + +> 한 파일만 최소 변경. 설명 불요. + +```patch +*** Begin Patch +*** Update File: src/main/java/org/mandarin/booking/adapter/webapi/HealthController.java +@@ + @GetMapping("/health") + public ResponseEntity health() { +- return ResponseEntity.ok("OK"); ++ return ResponseEntity.ok("OK"); // idempotent health check + } +*** End Patch +``` + +## 12) 빠른 참조(경로) + +* 엔트리포인트: `application/src/main/java/org/mandarin/booking/BookingApplication.java` +* Web API: `application/src/main/java/org/mandarin/booking/adapter/webapi/*` +* Security: `internal/src/main/java/org/mandarin/booking/adapter/*` (+ + `application/.../adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java`) +* App/Ports/Persist: `application/src/main/java/org/mandarin/booking/app/*` +* Domain: `domain/src/main/java/org/mandarin/booking/domain/*` + +--- -- `build.gradle`: 플러그인/의존성/테스트 태스크 -- `settings.gradle`: 단일 모듈 확인 -- `src/main/resources/application*.yml`: 프로필/환경 설정 -- `compose.yaml`: 로컬 환경에서 MySQL 기동 설정 -- `docs/specs/policy/application.md`: 아키텍처 및 작업 규칙 -- `src/main/java/.../SecurityConfig.java`, `JwtFilter.java`: 보안 규칙 -- `src/main/java/.../app/persist/*.java`: 영속성 포트/리포지토리 구조 +**메모**: 위 항목 외의 지시/템플릿/서술은 제거하십시오. 필요한 경우에만 최소한으로 추가하세요. diff --git a/README.md b/README.md index c8be2a1..aaebea4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ - 패키지 구조 예 - 도메인: `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}/*` + - 웹 어댑터: `application/src/main/java/org/mandarin/booking/adapter/webapi/*` + - 보안 설정: `internal/src/main/java/org/mandarin/booking/adapter/*`, + `application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java` 텍스트 다이어그램: [Controllers/Security/External] → adapter → app(ports, services) → domain @@ -74,7 +76,7 @@ - 테스트 주도 개발(TDD) 지향: 테스트 우선, 기능 추가 시 관련 스펙 테스트 동반. - 테스트 정책 문서: [docs/specs/policy/test.md](docs/specs/policy/test.md) - 통합 테스트: Spring Context 기동, 보안 필터/컨트롤러/JPA 연동을 포함한 경로 검증. - - 예시: `src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` + - 예시: `application/src/test/java/org/mandarin/booking/webapi/**/POST_specs.java` - 모듈 구조 테스트: 모듈간 의존관계 테스트 - 예시: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java` @@ -89,14 +91,15 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - 차후 추가 작성 - 예외 처리: `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler` -근거: `src/main/java/org/mandarin/booking/adapter/security/*` +근거: `internal/src/main/java/org/mandarin/booking/adapter/*`, +`application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java` --- ## 8. 데이터/환경 구성 - 프로필: `local`(기본), `test`, `prod(비어있음)` - - 근거: `src/main/resources/application.yml` 및 `application-*.yml` + - 근거: `application/src/main/resources/application.yml` 및 `application-*.yml` - local: MySQL + JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 - 근거: `application-local.yml`, Docker Compose: [compose.yaml](application/src/main/resources/compose.yaml) - test: H2 메모리 + MySQL 호환 모드 + JPA `ddl-auto: create` @@ -133,9 +136,9 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile ## 11. 버전/도구 근거 링크 - 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` + 버전: [root build.gradle](build.gradle), [gradle-wrapper.properties](gradle/wrapper/gradle-wrapper.properties) +- 애플리케이션 엔트리포인트: `application/src/main/java/org/mandarin/booking/BookingApplication.java` +- 보안 설정/필터: `internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java`, + `internal/src/main/java/org/mandarin/booking/adapter/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/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java index 2b12f8f..ba3d672 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java +++ b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java @@ -20,6 +20,7 @@ public void authorizeRequests( .requestMatchers(HttpMethod.GET, "/api/show").permitAll() .requestMatchers(HttpMethod.GET, "/api/show/*").permitAll()// 인증이 필요한 GET /show/* 엔드포인트 추가시 설정을 이 줄 아래에 .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") + .requestMatchers(HttpMethod.POST, "/api/hall").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated(); } } diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java new file mode 100644 index 0000000..d64268d --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java @@ -0,0 +1,21 @@ +package org.mandarin.booking.adapter.webapi; + +import jakarta.validation.Valid; +import org.mandarin.booking.app.hall.HallRegisterer; +import org.mandarin.booking.domain.hall.HallRegisterRequest; +import org.mandarin.booking.domain.hall.HallRegisterResponse; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/hall") +record HallController(HallRegisterer registerer) { + @PostMapping + HallRegisterResponse register(Authentication authentication, + @RequestBody @Valid HallRegisterRequest request) { + return registerer.register(authentication.getName(), request); + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java new file mode 100644 index 0000000..8a3dc2e --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.app.hall; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.hall.Hall; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +class HallCommandRepository { + private final HallRepository jpaRepository; + + + public Hall insert(Hall hall) { + return jpaRepository.save(hall); + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java index 382defd..694b3e8 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java @@ -16,7 +16,11 @@ boolean existsById(Long hallId) { return repository.existsById(hallId); } - public Hall findById(Long hallId) { + boolean existsByHallName(String hallName) { + return repository.existsByHallName(hallName); + } + + Hall findById(Long hallId) { return repository.findById(hallId) .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); } diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallRegisterer.java b/application/src/main/java/org/mandarin/booking/app/hall/HallRegisterer.java new file mode 100644 index 0000000..d56555a --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallRegisterer.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.hall; + +import org.mandarin.booking.domain.hall.HallRegisterRequest; +import org.mandarin.booking.domain.hall.HallRegisterResponse; + +public interface HallRegisterer { + HallRegisterResponse register(String userId, HallRegisterRequest request); +} diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java index 49f6f53..43cd492 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java @@ -8,4 +8,8 @@ interface HallRepository extends Repository { boolean existsById(Long id); Optional findById(Long id); + + boolean existsByHallName(String hallName); + + Hall save(Hall hall); } diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallService.java b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java index 9c0063a..7517baa 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallService.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java @@ -3,22 +3,43 @@ import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.HallException; +import org.mandarin.booking.domain.hall.HallRegisterRequest; +import org.mandarin.booking.domain.hall.HallRegisterResponse; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor -class HallService implements HallValidator, HallFetcher { +class HallService implements HallValidator, HallFetcher, HallRegisterer { private final HallQueryRepository queryRepository; + private final HallCommandRepository commandRepository; @Override - public void checkHallExist(Long hallId) { + public Hall fetch(Long hallId) { + return queryRepository.findById(hallId); + } + + @Override + public void checkHallExistByHallId(Long hallId) { if (!queryRepository.existsById(hallId)) { throw new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다."); } } @Override - public Hall fetch(Long hallId) { - return queryRepository.findById(hallId); + public void checkHallExistByHallName(String hallName) { + if (queryRepository.existsByHallName(hallName)) { + throw new HallException("INTERNAL_SERVER_ERROR", "이미 존재하는 공연장 이름입니다."); + } + } + + @Override + public HallRegisterResponse register(String userId, HallRegisterRequest request) { + checkHallExistByHallName(request.hallName()); + + var hall = Hall.create(request.hallName(), userId); + + var saved = commandRepository.insert(hall); + + return new HallRegisterResponse(saved.getId()); } } diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java index 869d9f2..4d54553 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java @@ -1,5 +1,7 @@ package org.mandarin.booking.app.hall; public interface HallValidator { - void checkHallExist(Long hallId); + void checkHallExistByHallId(Long hallId); + + void checkHallExistByHallName(String hallName); } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java deleted file mode 100644 index f104cfb..0000000 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.mandarin.booking.app.member; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.member.MemberException; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -class MemberRegisterValidator { - private final MemberQueryRepository queryRepository; - - void checkDuplicateEmail(String email) { - if (queryRepository.existsByEmail(email)) { - throw new MemberException("이미 존재하는 이메일입니다: " + email); - } - } - - void checkDuplicateUserId(String userId) { - if (queryRepository.existsByUserId(userId)) { - throw new MemberException("이미 존재하는 회원입니다: " + userId); - } - } -} diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java index 6b0a658..566f0c3 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.MemberException; import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -10,15 +11,15 @@ @Service @RequiredArgsConstructor -class MemberService implements MemberRegisterer { - private final MemberRegisterValidator validator; +class MemberService implements MemberRegisterer, MemberValidator { private final MemberCommandRepository command; + private final MemberQueryRepository queryRepository; private final SecurePasswordEncoder securePasswordEncoder; @Override public MemberRegisterResponse register(MemberRegisterRequest request) { - validator.checkDuplicateUserId(request.userId()); - validator.checkDuplicateEmail(request.email()); + checkDuplicateUserId(request.userId()); + checkDuplicateEmail(request.email()); var createCommand = new MemberCreateCommand(request.nickName(), request.userId(), request.password(), request.email()); @@ -26,4 +27,18 @@ public MemberRegisterResponse register(MemberRegisterRequest request) { var savedMember = this.command.insert(newMember); return MemberRegisterResponse.from(savedMember); } + + @Override + public void checkDuplicateEmail(String email) { + if (queryRepository.existsByEmail(email)) { + throw new MemberException("이미 존재하는 이메일입니다: " + email); + } + } + + @Override + public void checkDuplicateUserId(String userId) { + if (queryRepository.existsByUserId(userId)) { + throw new MemberException("이미 존재하는 회원입니다: " + userId); + } + } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java new file mode 100644 index 0000000..4138632 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.app.member; + +public interface MemberValidator { + void checkDuplicateEmail(String email); + + void checkDuplicateUserId(String userId); +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index c32473c..cf2fa61 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -71,7 +71,7 @@ SliceView fetch(@Nullable Integer page, show.type, show.rating, show.posterUrl, - select(hall.name) + select(hall.hallName) .from(hall) .where(hall.id.eq(show.hallId)), show.performanceStartDate, diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 6b0aebc..2fa944c 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -34,7 +34,7 @@ class ShowService implements ShowRegisterer, ShowFetcher { public ShowRegisterResponse register(ShowRegisterRequest request) { var hallId = request.hallId(); - hallValidator.checkHallExist(hallId); + hallValidator.checkHallExistByHallId(hallId); var command = ShowCreateCommand.from(request); var show = Show.create(hallId, command); @@ -78,7 +78,7 @@ public ShowDetailResponse fetchShowDetail(Long showId) { show.getPerformanceStartDate(), show.getPerformanceEndDate(), hall.getId(), - hall.getName(), + hall.getHallName(), show.getScheduleResponses() ); } diff --git a/application/src/test/java/org/mandarin/booking/utils/HallFixture.java b/application/src/test/java/org/mandarin/booking/utils/HallFixture.java new file mode 100644 index 0000000..7d01045 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/HallFixture.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.utils; + +import java.util.UUID; + +public class HallFixture { + public static String generateHallName() { + return UUID.randomUUID().toString().substring(0, 10); + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index d40ab3c..9d74970 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -41,7 +41,7 @@ public String getValidRefreshToken() { } public String getAuthToken() { - var member = fixture.insertDummyMember(); + var member = fixture.getOrCreateDefaultMember(); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } @@ -62,4 +62,3 @@ public String getAuthToken(MemberAuthority... memberAuthority) { .accessToken(); } } - diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index 741a42f..d637335 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; @TestConfiguration public class TestConfig { @@ -26,4 +27,20 @@ public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixt public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEncoder) { return new TestFixture(entityManager, securePasswordEncoder); } + + @Bean + @Primary + public SecurePasswordEncoder testPasswordEncoder() { + return new SecurePasswordEncoder() { + @Override + public String encode(String password) { + return "ENC:" + (password == null ? "" : password); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encode(rawPassword).equals(encodedPassword); + } + }; + } } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 23620d2..e8172c1 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -1,6 +1,8 @@ package org.mandarin.booking.utils; +import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.utils.EnumFixture.randomEnum; +import static org.mandarin.booking.utils.HallFixture.generateHallName; 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; @@ -32,12 +34,26 @@ public class TestFixture { private final EntityManager entityManager; private final SecurePasswordEncoder securePasswordEncoder; + private volatile Member cachedDefaultMember; public TestFixture(EntityManager entityManager, SecurePasswordEncoder securePasswordEncoder) { this.entityManager = entityManager; this.securePasswordEncoder = securePasswordEncoder; } + public Member getOrCreateDefaultMember() { + Member local = cachedDefaultMember; + if (local != null) { + return local; + } + synchronized (this) { + if (cachedDefaultMember == null) { + cachedDefaultMember = insertDummyMember(generateUserId(), generatePassword()); + } + return cachedDefaultMember; + } + } + public Member insertDummyMember(String userId, String password) { var command = new MemberCreateCommand( generateNickName(), @@ -64,12 +80,13 @@ public Member insertDummyMember(String userId, String nickName, List generateShows(int showCount) { - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); return IntStream.range(0, showCount) .mapToObj(i -> generateShow(hall.getId())) .toList(); } public void generateShows(int showCount, Type type) { - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); IntStream.range(0, showCount) .forEach(i -> generateShow(hall.getId(), type)); } public void generateShows(int showCount, Rating rating) { - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); IntStream.range(0, showCount) .forEach(i -> generateShow(hall.getId(), rating)); } public void generateShows(int showCount, String titlePart) { Random random = new Random(); - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); IntStream.range(0, showCount) .forEach(i -> { var request = validShowRegisterRequest(hall.getId(), @@ -145,7 +162,7 @@ public void generateShows(int showCount, String titlePart) { public void generateShows(int showCount, int before, int after) { Random random = new Random(); - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); var hallId = hall.getId(); IntStream.range(0, showCount) .forEach(i -> { @@ -165,7 +182,7 @@ public void generateShows(int showCount, int before, int after) { } public Show generateShowWithNoSynopsis(int scheduleCount) { - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); var show = Show.create(hall.getId(), ShowCreateCommand.from(new ShowRegisterRequest( hall.getId(), UUID.randomUUID().toString().substring(0, 10), @@ -191,9 +208,9 @@ public Show generateShowWithNoSynopsis(int scheduleCount) { return showInsert(show); } - public boolean existsHallName(String name) { - return (entityManager.createQuery("SELECT COUNT(h) FROM Hall h WHERE h.name = :name") - .setParameter("name", name) + public boolean existsHallName(String hallName) { + return (entityManager.createQuery("SELECT COUNT(h) FROM Hall h WHERE h.hallName = :hallName") + .setParameter("hallName", hallName) .getSingleResult() instanceof Long count) && count > 0; } diff --git a/application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java new file mode 100644 index 0000000..11019ff --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java @@ -0,0 +1,427 @@ +package org.mandarin.booking.webapi.hall; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.MemberAuthority.ADMIN; +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.SUCCESS; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.utils.HallFixture.generateHallName; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mandarin.booking.domain.hall.HallRegisterRequest; +import org.mandarin.booking.domain.hall.HallRegisterResponse; +import org.mandarin.booking.domain.hall.SeatRegisterRequest; +import org.mandarin.booking.domain.hall.SectionRegisterRequest; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("POST /api/hall") +class POST_specs { + + @Test + void ADMIN_권한의_토큰과_유효_본문으로_요청하면_SUCCESS와_hallId를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest( + "hallName", + List.of( + new SectionRegisterRequest( + "sectionName", + List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("B", "2") + ) + ), + new SectionRegisterRequest( + "sectionName2", + List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("B", "2") + ) + ) + ) + ); + + // Act + var response = testUtils.post("/api/hall", + request + ) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertSuccess(HallRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + + @Test + void 비ADMIN_토큰으로_요청하면_ACCESS_DENIED을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("hallName", List.of()); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(USER)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + } + + @Test + void 토큰이_무효하면_UNAUTHORIZED을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("hallName", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "B") + )) + )); + var invalidToken = ""; + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(invalidToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + } + + @Test + void name이_비어있으면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "B") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void sections_빈_배열이면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", Collections.emptyList()); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void section_name이_비어있으면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("", List.of( + new SeatRegisterRequest("A", "B") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void seats_빈_배열이면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", Collections.emptyList()) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + + @ParameterizedTest + @CsvSource({ + "'', '1'", + "'A', ''", + "' ', '1'", + "'A', ' '" + }) + void rowNumber_또는_seatNumber가_빈_문자인_경우_BAD_REQUEST을_반환한다( + String rowNumber, + String seatNumber, + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest(rowNumber, seatNumber) + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 동일_섹션_내_rowNumber와_seatNumber의_조합이_중복이면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("A", "1") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 섹션_이름이_중복되면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )), + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void hall을_등록하면_등록한_사용자_정보도_저장된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var request = new HallRegisterRequest(generateHallName(), List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )) + )); + var member = testFixture.insertDummyMember(ADMIN); + var authToken = testUtils.getAuthToken(member); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(authToken) + .assertSuccess(HallRegisterResponse.class); + + // Assert + var hall = testFixture.findHallById(response.getData().hallId()); + assertThat(hall.getRegistantId()).isEqualTo(member.getUserId()); + } + + @Test + void hall_name이_중복되면_INTERNAL_SERVER_ERROR을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var hallName = "name"; + var prevRequest = new HallRegisterRequest(hallName, List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )) + )); + var authToken = testUtils.getAuthToken(ADMIN); + testUtils.post("/api/hall", prevRequest) + .withAuthorization(authToken) + .assertSuccess(HallRegisterResponse.class); + var request = new HallRegisterRequest(hallName, List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )))); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(INTERNAL_SERVER_ERROR); + } + + @Test + void section_name이_중복되면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var sectionName = "sectionName"; + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest(sectionName, List.of( + new SeatRegisterRequest("A", "1") + )), + new SectionRegisterRequest(sectionName, List.of( + new SeatRegisterRequest("A", "1") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("Duplicate section names are not allowed"); + } + + @Test + void 동일한_section_내에_중복된_죄석을_요청하면_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("A", "1") + )) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @ParameterizedTest + @MethodSource("org.mandarin.booking.webapi.hall.POST_specs#blankNameRequests") + void hall_하위_정보가_잘못된_경우_hall도_저장되지_않는다( + HallRegisterRequest request,// Arrange + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Act + testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + var hallName = request.hallName(); + assertThat(testFixture.existsHallName(hallName)).isFalse(); + } + + @Test + void sections가_비어있으면_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of()); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("At least one section is required"); + } + + @Test + void section의_seats가_비어있으면_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of()) + )); + + // Act + var response = testUtils.post("/api/hall", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).contains("At least one seat is required"); + + } + + private static List blankNameRequests() { + return List.of( + new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("A", "1") + )) + )),// 동일 좌석 + new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("A", "2") + )), + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1"), + new SeatRegisterRequest("A", "2") + )) + + ))// 동일 구역 이름 + ); + } +} diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 51c7b44..0db493e 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -2,7 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mandarin.booking.MemberAuthority.ADMIN; +import static org.mandarin.booking.MemberAuthority.DISTRIBUTOR; 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; @@ -55,7 +57,7 @@ static List nullOrBlankElementRequests() { ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); + var request = validShowRegisterRequest(testFixture.insertDummyHall("userId").getId()); // Act var response = testUtils.post( @@ -75,7 +77,7 @@ static List nullOrBlankElementRequests() { @Autowired TestFixture testFixture ) { // Arrange - var hallId = testFixture.insertDummyHall().getId(); + var hallId = testFixture.insertDummyHall("userId").getId(); var request = validShowRegisterRequest(hallId); // Act @@ -91,7 +93,7 @@ static List nullOrBlankElementRequests() { @ParameterizedTest @MethodSource("org.mandarin.booking.webapi.show.POST_specs#nullOrBlankElementRequests") - void title_type_rating_synopsis_posterUrl_performanceDates가_비어있으면_BAD_REQUEST이다( + void title_type_rating_synopsis_posterUrl_performanceStartDate_performanceEndDate가_비어있으면_BAD_REQUEST이다( ShowRegisterRequest request, @Autowired IntegrationTestUtils testUtils ) { @@ -117,7 +119,7 @@ static List nullOrBlankElementRequests() { ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var hallId = testFixture.insertDummyHall().getId(); + var hallId = testFixture.insertDummyHall("userId").getId(); var request = new ShowRegisterRequest( hallId, "공연 제목", @@ -148,7 +150,7 @@ static List nullOrBlankElementRequests() { ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var hallId = testFixture.insertDummyHall().getId(); + var hallId = testFixture.insertDummyHall("userId").getId(); var request = validShowRegisterRequest(hallId); // Act @@ -170,7 +172,7 @@ static List nullOrBlankElementRequests() { ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var hallId = testFixture.insertDummyHall().getId(); + var hallId = testFixture.insertDummyHall("userId").getId(); var request = new ShowRegisterRequest( hallId, "공연 제목", @@ -204,7 +206,7 @@ static List nullOrBlankElementRequests() { ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); + var request = validShowRegisterRequest(testFixture.insertDummyHall("userId").getId()); testUtils.post( "/api/show", request @@ -248,6 +250,26 @@ static List nullOrBlankElementRequests() { assertThat(response.getStatus()).isEqualTo(NOT_FOUND); } + @Test + void 비ADMIN_토큰으로_요청하면_FORBIDDEN_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var request = validShowRegisterRequest(testFixture.insertDummyHall("userId").getId()); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(FORBIDDEN); + } + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( hallId, diff --git a/application/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 index aedebdd..96a09b9 100644 --- a/application/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 @@ -49,7 +49,7 @@ public class POST_specs { } @Test - void ADMIN_권한을_가진_사용자가_올바른_요청을_하는_경우_SUCCESS_상태코드를_반환한다( + void DISTRIBUTOR_권한을_가진_사용자가_올바른_요청을_하는_경우_SUCCESS_상태코드를_반환한다( @Autowired IntegrationTestUtils testUtils, @Autowired TestFixture testFixture ) { diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java index 216954b..83d94b1 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java @@ -92,7 +92,7 @@ class GET_specs { var fetched = testFixture.findHallById(hallId); assertThat(fetched).isNotNull(); - assertThat(fetched.getName()).isEqualTo(hallName); + assertThat(fetched.getHallName()).isEqualTo(hallName); assertThat(fetched.getId()).isEqualTo(hallId); } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md new file mode 100644 index 0000000..ce95b97 --- /dev/null +++ b/docs/specs/api/hall_register.md @@ -0,0 +1,83 @@ +### 요청 + +- 메서드: `POST` +- 경로: `/api/hall` +- 헤더 + + ``` + Content-Type: application/json + Authorization: Bearer + ``` + +- 본문 예시 (Hall → Section → Seat 구조) + + ```json + { + "name": "Seoul Art Hall", + "sections": [ + { + "name": "A", + "seats": [ + { "rowNumber": 1, "seatNumber": 1 }, + { "rowNumber": 1, "seatNumber": 2 } + ] + }, + { + "name": "B", + "seats": [ + { "rowNumber": 1, "seatNumber": 1 } + ] + } + ] + } + ``` + +- curl 명령 예시 + + ```bash + curl -i -X POST 'http://localhost:8080/api/hall' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ + -d '{ + "name": "Seoul Art Hall", + "sections": [ + { "name": "A", "seats": [ { "rowNumber": 1, "seatNumber": 1 }, { "rowNumber": 1, "seatNumber": 2 } ] }, + { "name": "B", "seats": [ { "rowNumber": 1, "seatNumber": 1 } ] } + ] + }' + ``` + +--- + +### 응답 + +- 상태코드: `200 OK` +- 본문 예시 + + ```json + { + "hallId": 1 + } + ``` + +--- + +### 테스트 + +- [x] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 +- [x] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 +- [x] 토큰이 무효하면 UNAUTHORIZED을 반환한다 +- [x] name이 비어있으면 BAD_REQUEST을 반환한다 +- [x] sections 빈 배열이면 BAD_REQUEST을 반환한다 +- [x] section name이 비어있으면 BAD_REQUEST을 반환한다 +- [x] seats 빈 배열이면 BAD_REQUEST을 반환한다 +- [x] rowNumber 또는 seatNumber가 빈 문자인 경우 BAD_REQUEST을 반환한다 +- [x] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 +- [x] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 +- [x] hall을 등록하면 등록한 사용자 정보도 저장된다 +- [x] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 +- [x] section name이 중복되면 BAD_REQUEST을 반환한다 +- [x] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 +- [x] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 +- [x] sections가 비어있으면 BAD_REQUEST를 반환한다 +- [x] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 diff --git a/docs/specs/api/member_register.md b/docs/specs/api/member_register.md index 616a242..9a275d8 100644 --- a/docs/specs/api/member_register.md +++ b/docs/specs/api/member_register.md @@ -6,7 +6,6 @@ ``` Content-Type: application/json - Authorization: Bearer ``` - 본문 diff --git a/docs/specs/api/reissue.md b/docs/specs/api/reissue.md index 8f8941d..870804d 100644 --- a/docs/specs/api/reissue.md +++ b/docs/specs/api/reissue.md @@ -20,7 +20,7 @@ - curl 명령 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/auth/refresh' \ + curl -i -X POST 'http://localhost:8080/api/auth/reissue' \ -H 'Content-Type: application/json' \ -d '{ "refreshToken":"refreshrefreshrefreshrefreshrefreshrefreshrefreshrefreshrefreshrefresh" @@ -44,9 +44,9 @@ - [x] 올바른 refresh token으로 요청하면 200을 응답한다 - [x] 올바른 refresh token으로 요청하면 새로운 access token과 refresh token을 발급해 응답한다 -- [x] 응답받은 access token과 refresh toke은 유효한 JWT 형식이다 -- [x] 응답받은 access token과 refresh toke은 만료되지 않아야 한다 +- [x] 응답받은 access token과 refresh token은 유효한 JWT 형식이다 +- [x] 응답받은 access token과 refresh token은 만료되지 않아야 한다 - [x] 요청 토큰의 서명이 잘못된 경우 401 Unauthorized가 발생한다 - [x] 요청 body가 누락된 경우 400 Bad Request가 발생한다 -- [x] 만료된 refresh token으로 요청하면 401 Unauthorize가 발생한다 -- [x] 존재하지 않는 사용자의 refresh token을 요청하면 401 Unauthorize가 발생한다 +- [x] 만료된 refresh token으로 요청하면 401 Unauthorized가 발생한다 +- [x] 존재하지 않는 사용자의 refresh token을 요청하면 401 Unauthorized가 발생한다 diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 4f9e46e..76c070e 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -36,8 +36,8 @@ - from > to 인 경우: 400 BAD_REQUEST - 정렬: - 1) performanceStartDate ASC - 2) title ASC(title이 unique기 때문에 마지막 정렬조건으로 충분) + 1) performanceStartDate DESC + 2) title ASC (title이 unique이므로 마지막 정렬조건으로 충분) - 페이지네이션: - page는 0-기반 인덱스다. diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index cc0858a..222edb4 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -62,9 +62,10 @@ - [x] 올바른 요청을 보내면 status가 SUCCESS이다 - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 -- [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 -- [x] 허용되지 않은 타입이면 BAD_REQUEST이다 +- [x] title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate가 비어있으면 BAD_REQUEST이다 +- [x] 허용되지 않은 type/rating이면 BAD_REQUEST이다 - [x] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 - [x] 공연 시작일은 공연 종료일 이후면 INTERNAL_SERVER_ERROR이다 - [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 - [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 +- [x] 비ADMIN 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 933a655..9dab404 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -42,7 +42,7 @@ ```json { - "showId": 1 + "scheduleId": 1 } ``` @@ -51,7 +51,7 @@ ### 테스트 - [x] 올바른 접근 토큰과 유효한 요청을 보내면 SUCCESS 상태코드를 반환한다 -- [x] ADMIN 권한을 가진 사용자가 올바른 요청을 하는 경우 SUCCESS 상태코드를 반환한다 +- [x] DISTRIBUTOR 권한을 가진 사용자가 올바른 요청을 하는 경우 SUCCESS 상태코드를 반환한다 (ADMIN도 권한 계층에 따라 허용) - [x] 응답 본문에 scheduleId가 포함된다 - [x] 권한이 없는 사용자 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 - [x] runtimeMinutes가 0 이하일 경우 BAD_REQUEST를 반환한다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index 5b888fc..abc8976 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -280,7 +280,9 @@ erDiagram %% UNIQUE(scheduleId, roleName) on Casting %% Hall AR (Hall 내부에 Seat/TicketGrade) - Hall ||--o{ Seat : has + Hall ||--o{ Section : has + Section ||--o{ Seat : has + Seat ||--o{ TicketGrade : has Hall ||--o{ TicketGrade : has %% Reservation AR (Payment/Attempt/Refund 내부 포함) @@ -335,10 +337,16 @@ erDiagram BIGINT id PK string name } + + Section { + BIGINT id PK + BIGINT hallId FK + string name + } Seat { BIGINT id PK - BIGINT hallId FK + BIGINT sectionId FK string rowLabel int number enum viewGrade "NORMAL|PARTIAL_VIEW|OBSTRUCTED" diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md index 5f15a03..89eed0b 100644 --- a/docs/specs/policy/application.md +++ b/docs/specs/policy/application.md @@ -75,12 +75,10 @@ ## 3. 포트와 어댑터 -- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `application/aggregate-root` 컨트롤러는 입력 포트를 통해서만 유스케이스 호출. -- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `application/aggregate-root` 또는 `application/aggregate-root` 하위에 정의 - 가능. -- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: adapter 하위. 현재 JPA 기반 구현은 `application/aggregate-root/*Repository`를 통해 동작하며, 해당 패키지는 - 어댑터 계층으로 - 간주합니다. +- 입력 포트(inbound port): 유스케이스 인터페이스. 위치: `application/app/*`. +- 출력 포트(outbound port): 외부 시스템/리포지토리에 대한 인터페이스. 위치: `application/app/*` 하위에 정의. +- 어댑터(adapters): 포트 인터페이스의 구현체. 위치: `application/adapter/*` 등. 현재 JPA 기반 구현은 `application/app/*Repository`를 통해 동작하며 해당 + 패키지는 어댑터 계층으로 간주. 권장 네이밍: @@ -176,12 +174,16 @@ flowchart TB ## 8. 보안 규칙 -- 인증/인가 컴포넌트는 adapter/security에 위치: `JwtFilter`, `CustomAuthenticationProvider`, `SecurityConfig` 등. -- 보안 컨텍스트와 토큰 파싱은 어댑터에서 처리하고, application 유스케이스에는 인증된 식별자/역할만 전달. +- 인증/인가 컴포넌트 위치: + - `internal/adapter`: `SecurityConfig`, `JwtFilter`, `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler`, + `TokenUtils` + - `application/adapter/security`: `ApplicationAuthorizationRequestMatcherConfigurer` + - `application/app/member`: `CustomAuthenticationProvider` +- 보안 컨텍스트/토큰 파싱은 어댑터에서 처리하고, application 유스케이스에는 인증된 식별자/역할만 전달. ## 9. 테스트 규칙 -- `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` 는 아키텍처 규칙을 자동 검증합니다. +- `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java` 는 아키텍처 규칙을 자동 검증합니다. - 규칙 위반 예: - adapter가 application 서비스 구현 클래스에 직접 의존 - application이 adapter 패키지에 의존 diff --git a/docs/specs/policy/authentication.md b/docs/specs/policy/authentication.md index 0264c64..3f934fa 100644 --- a/docs/specs/policy/authentication.md +++ b/docs/specs/policy/authentication.md @@ -4,13 +4,16 @@ 근거 파일/경로: -- 보안 설정: `src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java` -- JWT 필터: `src/main/java/org/mandarin/booking/adapter/security/JwtFilter.java` -- AuthenticationProvider: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java` -- 토큰 유틸: `src/main/java/org/mandarin/booking/app/TokenUtils.java` -- 예외 처리기: `src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationEntryPoint.java`, - `CustomAccessDeniedHandler.java` -- 프로필/환경: `src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` +- 보안 설정: `internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java` +- JWT 필터: `internal/src/main/java/org/mandarin/booking/adapter/JwtFilter.java` +- AuthenticationProvider: `application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java` +- 토큰 유틸: `internal/src/main/java/org/mandarin/booking/adapter/JwtTokenUtils.java`, + `internal/src/main/java/org/mandarin/booking/adapter/TokenUtils.java` +- 예외 처리기: `internal/src/main/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPoint.java`, + `internal/src/main/java/org/mandarin/booking/adapter/CustomAccessDeniedHandler.java` +- 엔드포인트 권한 매칭: + `application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java` +- 프로필/환경: `application/src/main/resources/application.yml`, `application-local.yml`, `application-test.yml` --- @@ -78,12 +81,16 @@ ## 5. 공개 엔드포인트와 인증 필요 엔드포인트 -- 공개(permitAll): (근거: SecurityConfig.apiChain) +- 공개(permitAll): (근거: SecurityConfig.apiChain, ApplicationAuthorizationRequestMatcherConfigurer) - `POST /api/member` - `POST /api/auth/login` - `POST /api/auth/reissue` -- 인증 필요: (근거: SecurityConfig.apiChain) - - 위 공개 엔드포인트를 제외한 모든 `/api/**` 요청은 인증 필요 + - `GET /api/show`, `GET /api/show/*` +- 인증/권한 필요: (근거: ApplicationAuthorizationRequestMatcherConfigurer) + - `POST /api/hall` → `ROLE_ADMIN` + - `POST /api/show` → `ROLE_ADMIN` + - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` + - 그 외 `/api/**` → 인증 필요 - 공개 체인(@Order(2)): (근거: SecurityConfig.publicChain) - `/error`, `/assets/**`, `/favicon.ico` 및 그 외 `/**`는 permitAll (운영상 공개 페이지용) @@ -93,7 +100,8 @@ - 테스트 실행 시 프로필 `test` 활성화: Gradle test 태스크에서 설정 (근거: build.gradle) - 테스트 프로필에서 JWT/DB 설정은 `application-test.yml`을 따른다. -- 보안 관련 통합/단위 테스트는 다음을 참조: `src/test/java/org/mandarin/booking/adapter/security/*` +- 보안 관련 테스트는 다음을 참조: `application/src/test/java/org/mandarin/booking/adapter/security/*`, + `internal/src/test/java/org/mandarin/booking/adapter/*Test.java` --- @@ -111,4 +119,3 @@ - 토큰 발급/서명 구현 세부(Access/Refresh 생성 로직) 문서화 수준: 확인 불가 (해당 클래스 상세는 별도 코드 참조 필요) - 키 회전/블랙리스트/토큰 철회 전략: 확인 불가 - diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md index 632a0c4..916df94 100644 --- a/docs/specs/policy/authorization.md +++ b/docs/specs/policy/authorization.md @@ -4,11 +4,13 @@ 근거 파일/경로: -- 보안 설정: `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` +- 보안 설정: `internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java` +- 권한 Enum: `common/src/main/java/org/mandarin/booking/MemberAuthority.java` +- JWT 필터: `internal/src/main/java/org/mandarin/booking/adapter/JwtFilter.java` +- 권한 매칭 구성: + `application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java` +- 예외 처리기: `internal/src/main/java/org/mandarin/booking/adapter/CustomAccessDeniedHandler.java`, + `internal/src/main/java/org/mandarin/booking/adapter/CustomAuthenticationEntryPoint.java` --- @@ -28,12 +30,15 @@ - `POST /api/member` - `POST /api/auth/login` - `POST /api/auth/reissue` + - `GET /api/show`, `GET /api/show/*` - 권한 필요(hasAuthority): - - `POST /api/show` → `ROLE_DISTRIBUTOR` + - `POST /api/hall` → `ROLE_ADMIN` + - `POST /api/show` → `ROLE_ADMIN` + - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` - 그 외 `/api/**`: - - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음). 컨트롤러/도메인 단에서 별도 검증이 필요한 경우 추가 로직으로 보완. + - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음) - 퍼블릭 체인(@Order(2)): - `/error`, `/assets/**`, `/favicon.ico`, 및 그 외 `/**`는 permitAll (정적/오류/기타 공개 경로) @@ -44,7 +49,11 @@ - `.requestMatchers(HttpMethod.POST, "/api/member").permitAll()` - `.requestMatchers("/api/auth/login").permitAll()` - `.requestMatchers("/api/auth/reissue").permitAll()` -- `.requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_DISTRIBUTOR")` +- `.requestMatchers(HttpMethod.GET, "/api/show").permitAll()` +- `.requestMatchers(HttpMethod.GET, "/api/show/*").permitAll()` +- `.requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR")` +- `.requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN")` +- `.requestMatchers(HttpMethod.POST, "/api/hall").hasAuthority("ROLE_ADMIN")` - `.anyRequest().authenticated()` --- diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index d4ab220..b1d0fc3 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -5,9 +5,10 @@ 근거 파일/경로: -- build.gradle: test 태스크, 라이브러리, JVM args 설정 -- src/main/resources/application-test.yml: 테스트 프로필 환경 -- src/test/java/**/*: 실제 테스트 코드 일체 +- root build.gradle: test 태스크, 라이브러리, JVM args 설정 +- application/src/main/resources/application-test.yml: 테스트 프로필 환경 +- application/src/test/java/**/*: 실제 테스트 코드 일체(통합/웹/아키텍처) +- internal/src/test/java/**/*: 내부 어댑터 단위 테스트 - docs/specs/api/*.md: API별 수용 기준(체크리스트) --- @@ -21,7 +22,7 @@ 명령: - 전체 테스트: `./gradlew test` -- 테스트 프로필: Gradle가 자동으로 `spring.profiles.active=test`를 설정함 (build.gradle 근거). +- 테스트 프로필: Gradle가 자동으로 `spring.profiles.active=test`를 설정함 (root build.gradle 근거). --- @@ -40,7 +41,7 @@ - 라이브러리/설정 근거: - Mockito inline 사용: `build.gradle` → `testImplementation 'org.mockito:mockito-inline:5.2.0'` - JUnit5 사용: `build.gradle` → `useJUnitPlatform()` - - ByteBuddy javaagent 사전 부착: `build.gradle` → `jvmArgs "-javaagent:${configurations.byteBuddyAgent.singleFile}"` + - JVM 설정: `build.gradle` → `jvmArgs = ['-XX:+EnableDynamicAgentLoading', '-Xshare:off']` 권장 규칙: @@ -52,7 +53,8 @@ ### 2.2 통합 테스트 (Integration Test) - 목적: Spring Context를 실제로 기동하여, 보안 필터/컨트롤러/시리얼라이저/예외 처리 및 JPA/H2 동작을 포함해 엔드투엔드에 가까운 경로를 검증. -- 프로필/환경: `application-test.yml` 사용. H2 메모리 DB, JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 포함. +- 프로필/환경: `application/src/main/resources/application-test.yml` 사용. H2 메모리 DB, JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 + 포함. - 보안: 실제 `SecurityConfig`와 `JwtFilter` 동작을 최대한 반영. 필요 시 테스트 전용 컨트롤러/설정 (`TestOnlyController`, `TestConfig`) 사용. - 유틸리티: `IntegrationTest`, `IntegrationTestUtils`, `IntegrationTestUtilsSpecs`, `JwtTestUtils` 등 공용 유틸을 통해 테스트 준비/토큰 생성/컨텍스트 초기화. @@ -96,7 +98,7 @@ ### 2.3 아키텍처 테스트 (ArchUnit) - 목적: 헥사고날 계층 규칙 준수 보장. -- 근거 테스트: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` +- 근거 테스트: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java` - 핵심 규칙: - adapter 레이어는 어떤 레이어에도 접근 허용되지 않음(외부에서 접근 금지). - application 레이어는 adapter에서만 접근 가능. @@ -170,20 +172,21 @@ ## 8. 디렉터리/네이밍 가이드 -- 테스트 루트: `src/test/java` +- 테스트 루트: `application/src/test/java` - 관례: - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 - 예시) - - POST `/api/auth/login`: `src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` - - GET `/api/show`: `src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` + - POST `/api/auth/login`: `application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` + - GET `/api/show`: `application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` - 아키텍처 테스트: `arch/*` --- ## 9. 부록: 참고 클래스 -- ArchUnit: `src/test/java/org/mandarin/booking/arch/HexagonalArchitectureTest.java` -- 보안 테스트: `src/test/java/org/mandarin/booking/adapter/security/*.java` -- 웹 API 스펙: `src/test/java/org/mandarin/booking/webapi/**` -- 통합 유틸: `src/test/java/org/mandarin/booking/IntegrationTest*.java`, `JwtTestUtils.java` +- ArchUnit: `application/src/test/java/org/mandarin/booking/arch/ModuleDependencyRulesTest.java` +- 보안 테스트: `application/src/test/java/org/mandarin/booking/adapter/security/*.java`, + `internal/src/test/java/org/mandarin/booking/adapter/*Test.java` +- 웹 API 스펙: `application/src/test/java/org/mandarin/booking/webapi/**` +- 통합 유틸: `application/src/test/java/org/mandarin/booking/utils/*` diff --git a/docs/todo.md b/docs/todo.md index 2280043..c48b074 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,7 +29,7 @@ 2025.09.23 - [ ] hall register - - [ ] register with registerer name + - [ ] register with registant name - [ ] register with seats --- diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java index 7c74e77..7b6df17 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java @@ -1,8 +1,14 @@ package org.mandarin.booking.domain.hall; +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; + +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @@ -10,11 +16,21 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { - private String name; + @OneToMany(mappedBy = "hall", cascade = ALL, fetch = LAZY) + private List
sections = new ArrayList<>(); + + @Column(unique = true) + private String hallName; + + private String registantId; + + public Hall(String hallName, String registantId) { + this.hallName = hallName; + this.registantId = registantId; + } - public static Hall create(String name) { - return new Hall(name); + public static Hall create(String name, String registantId) { + return new Hall(name, registantId); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java new file mode 100644 index 0000000..e9ea593 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -0,0 +1,23 @@ +package org.mandarin.booking.domain.hall; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertFalse; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record HallRegisterRequest( + @NotBlank + String hallName, + @Size(min = 1, message = "At least one section is required") + @Valid + List sectionRegisterRequests) { + @AssertFalse(message = "Duplicate section names are not allowed") + public boolean hasDuplicateSectionNames() { + return sectionRegisterRequests.stream() + .map(SectionRegisterRequest::sectionName) + .distinct() + .count() != sectionRegisterRequests.size(); + } +} + diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterResponse.java b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterResponse.java new file mode 100644 index 0000000..7948d68 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterResponse.java @@ -0,0 +1,4 @@ +package org.mandarin.booking.domain.hall; + +public record HallRegisterResponse(Long hallId) { +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java new file mode 100644 index 0000000..31db7d2 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java @@ -0,0 +1,28 @@ +package org.mandarin.booking.domain.hall; + +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PACKAGE; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Getter(value = PACKAGE) +@Entity +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PRIVATE) +class Seat extends AbstractEntity { + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "section_id", nullable = false) + private Section section; + + private String rowNumber; + + private String seatNumber; +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java new file mode 100644 index 0000000..d362092 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.domain.hall; + +import jakarta.validation.constraints.NotBlank; + +public record SeatRegisterRequest( + @NotBlank + String rowNumber, + @NotBlank + String seatNumber) { +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java new file mode 100644 index 0000000..e2591e9 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java @@ -0,0 +1,33 @@ +package org.mandarin.booking.domain.hall; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PACKAGE; +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Getter(value = PACKAGE) +@Entity +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PRIVATE) +class Section extends AbstractEntity { + @ManyToOne(fetch = LAZY, optional = false) + @JoinColumn(name = "hall_id") + private Hall hall; + + @OneToMany(mappedBy = "section", cascade = ALL, fetch = LAZY) + private List seats = new ArrayList<>(); + + private String name; +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java new file mode 100644 index 0000000..8162ec4 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -0,0 +1,22 @@ +package org.mandarin.booking.domain.hall; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertFalse; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public record SectionRegisterRequest( + @NotBlank + String sectionName, + @Size(min = 1, message = "At least one seat is required") + @Valid + List seats) { + @AssertFalse(message = "Duplicate seats are not allowed") + public boolean hasDuplicateSeats() { + Set seen = new HashSet<>(); + return seats.stream().anyMatch(seat -> !seen.add(seat)); + } +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java b/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java index 0495c3f..c303783 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/ResponseWrapper.java @@ -1,6 +1,5 @@ package org.mandarin.booking.adapter; -import java.util.Objects; import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; @@ -24,6 +23,9 @@ public Object beforeBodyWrite(@Nullable final Object body, final MethodParameter final MediaType selectedContentType, final Class> selectedConverterType, final ServerHttpRequest request, final ServerHttpResponse response) { - return new SuccessResponse<>(ApiStatus.SUCCESS, Objects.requireNonNull(body)); + if (returnType.getMethod().getReturnType() == ErrorResponse.class) { + return body; + } + return new SuccessResponse<>(ApiStatus.SUCCESS, body); } } diff --git a/internal/src/test/java/org/mandarin/booking/adapter/ResponseWrapperTest.java b/internal/src/test/java/org/mandarin/booking/adapter/ResponseWrapperTest.java new file mode 100644 index 0000000..921d435 --- /dev/null +++ b/internal/src/test/java/org/mandarin/booking/adapter/ResponseWrapperTest.java @@ -0,0 +1,83 @@ +package org.mandarin.booking.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; + +class ResponseWrapperTest { + + private ResponseWrapper responseWrapper; + + @BeforeEach + void setUp() { + responseWrapper = new ResponseWrapper(); + } + + @Test + void shouldWrapNormalResponse() throws NoSuchMethodException { + // Arrange + Method method = DummyController.class.getMethod("normalResponse"); + MethodParameter methodParameter = new MethodParameter(method, -1); + + Object body = "hello world"; + + // Act + Object result = responseWrapper.beforeBodyWrite( + body, + methodParameter, + MediaType.APPLICATION_JSON, + MappingJackson2HttpMessageConverter.class, + mock(ServerHttpRequest.class), + mock(ServerHttpResponse.class) + ); + + // Assert + assertThat(result).isInstanceOf(SuccessResponse.class); + SuccessResponse success = (SuccessResponse) result; + assertThat(success.getData()).isEqualTo("hello world"); + assertThat(success.getStatus()).isEqualTo(SUCCESS); + } + + @Test + void shouldNotWrapErrorResponse() throws NoSuchMethodException { + // Arrange + Method method = DummyController.class.getMethod("errorResponse"); + MethodParameter methodParameter = new MethodParameter(method, -1); + + ErrorResponse errorResponse = new ErrorResponse(BAD_REQUEST, "something wrong"); + + // Act + Object result = responseWrapper.beforeBodyWrite( + errorResponse, + methodParameter, + MediaType.APPLICATION_JSON, + MappingJackson2HttpMessageConverter.class, + mock(ServerHttpRequest.class), + mock(ServerHttpResponse.class) + ); + + // Assert + assertThat(result).isInstanceOf(ErrorResponse.class); + assertThat(((ErrorResponse) result).getData()).isEqualTo("something wrong"); + } + + static class DummyController { + public String normalResponse() { + return "hello"; + } + + public ErrorResponse errorResponse() { + return new ErrorResponse(BAD_REQUEST, "something wrong"); + } + } +}