From b431eef46896e8d1ac8c402da895c2e12e755cb4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 29 Sep 2025 10:29:18 +0900 Subject: [PATCH 01/25] update AGENTS.md and related documentation for clarity and accuracy --- AGENTS.md | 408 ++++++++--------------- README.md | 19 +- docs/specs/api/member_register.md | 1 - docs/specs/api/reissue.md | 10 +- docs/specs/api/show_list_inquiry.md | 4 +- docs/specs/api/show_schedule_register.md | 2 +- docs/specs/policy/authentication.md | 30 +- docs/specs/policy/authorization.md | 18 +- docs/specs/policy/test.md | 27 +- 9 files changed, 196 insertions(+), 323 deletions(-) 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/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_schedule_register.md b/docs/specs/api/show_schedule_register.md index 933a655..b424573 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 } ``` diff --git a/docs/specs/policy/authentication.md b/docs/specs/policy/authentication.md index 0264c64..7abfa0b 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,15 @@ ## 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/show` → `ROLE_ADMIN` + - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` + - 그 외 `/api/**` → 인증 필요 - 공개 체인(@Order(2)): (근거: SecurityConfig.publicChain) - `/error`, `/assets/**`, `/favicon.ico` 및 그 외 `/**`는 permitAll (운영상 공개 페이지용) @@ -93,7 +99,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 +118,3 @@ - 토큰 발급/서명 구현 세부(Access/Refresh 생성 로직) 문서화 수준: 확인 불가 (해당 클래스 상세는 별도 코드 참조 필요) - 키 회전/블랙리스트/토큰 철회 전략: 확인 불가 - diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md index 632a0c4..5b46aee 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,14 @@ - `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/show` → `ROLE_ADMIN` + - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` - 그 외 `/api/**`: - - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음). 컨트롤러/도메인 단에서 별도 검증이 필요한 경우 추가 로직으로 보완. + - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음) - 퍼블릭 체인(@Order(2)): - `/error`, `/assets/**`, `/favicon.ico`, 및 그 외 `/**`는 permitAll (정적/오류/기타 공개 경로) diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index d4ab220..42250b1 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 근거). --- @@ -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` 등 공용 유틸을 통해 테스트 준비/토큰 생성/컨텍스트 초기화. @@ -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/*` From 5c51f4954a5d7f51c19d0886ec24e6cb5a838438 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 15:24:22 +0900 Subject: [PATCH 02/25] add hall registration API documentation with request/response examples and test cases --- docs/specs/api/hall_register.md | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/specs/api/hall_register.md diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md new file mode 100644 index 0000000..af3bbc6 --- /dev/null +++ b/docs/specs/api/hall_register.md @@ -0,0 +1,82 @@ +### 요청 + +- 메서드: `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 + } + ``` + +--- + +### 테스트 + +- [ ] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 +- [ ] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 +- [ ] 토큰 없거나 무효하면 UNAUTHORIZED을 반환한다 +- [ ] name이 비어있으면 BAD_REQUEST을 반환한다 +- [ ] sections 빈 배열이면 BAD_REQUEST을 반환한다 +- [ ] 섹션 name이 비어있으면 BAD_REQUEST을 반환한다 +- [ ] seats 빈 배열이면 BAD_REQUEST을 반환한다 +- [ ] rowNumber 또는 seatNumber가 1 미만이면 BAD_REQUEST을 반환한다 +- [ ] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 +- [ ] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 +- [ ] hall을 등록하면 등록한 사용자 정보도 저장된다 +- [ ] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 +- [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 +- [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 +- [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 +- [ ] 요청자가 ADMIN 권한이 없는 경우 ACCESS_DENIED를 반환한다 From e010b46ce4f0eb8ee5f1f76cbf0694fcc36c0b66 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 15:24:33 +0900 Subject: [PATCH 03/25] refactor domain model to introduce Section entity and update relationships with Seat and TicketGrade --- docs/specs/domain.md | 12 ++++++++++-- docs/todo.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) 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/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 --- From 690e71bbb0cdb64e3b01bfd5bb43c01d30c220f2 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 16:01:47 +0900 Subject: [PATCH 04/25] implement hall registration API with request/response models and validation rules --- .../adapter/webapi/HallController.java | 17 ++++++ .../mandarin/booking/utils/TestFixture.java | 2 +- .../booking/webapi/hall/POST_specs.java | 57 +++++++++++++++++++ docs/specs/api/hall_register.md | 3 + .../mandarin/booking/domain/hall/Hall.java | 22 +++++-- .../domain/hall/HallRegisterRequest.java | 7 +++ .../domain/hall/HallRegisterResponse.java | 4 ++ .../mandarin/booking/domain/hall/Seat.java | 28 +++++++++ .../domain/hall/SeatRegisterRequest.java | 4 ++ .../mandarin/booking/domain/hall/Section.java | 33 +++++++++++ .../domain/hall/SectionRegisterRequest.java | 7 +++ 11 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java create mode 100644 application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterResponse.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/Section.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java 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..e1529d2 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.adapter.webapi; + +import org.mandarin.booking.domain.hall.HallRegisterRequest; +import org.mandarin.booking.domain.hall.HallRegisterResponse; +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") +class HallController { + @PostMapping + HallRegisterResponse register(@RequestBody HallRegisterRequest request) { + return null; + } +} 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..ca3bf60 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -87,7 +87,7 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc } public Hall insertDummyHall() { - var hall = Hall.create("hall name"); + var hall = Hall.create("hall name", 1L); entityManager.persist(hall); return hall; } 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..965421e --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java @@ -0,0 +1,57 @@ +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.adapter.ApiStatus.SUCCESS; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.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); + } +} diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index af3bbc6..3c6e02c 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -80,3 +80,6 @@ - [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 - [ ] 요청자가 ADMIN 권한이 없는 경우 ACCESS_DENIED를 반환한다 +- [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 +- [ ] sections가 비어있으면 BAD_REQUEST를 반환한다 +- [ ] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 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..5f91fd2 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,13 @@ package org.mandarin.booking.domain.hall; +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.FetchType.LAZY; + 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 +15,20 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { + @OneToMany(mappedBy = "hall", cascade = ALL, fetch = LAZY) + private List
sections = new ArrayList<>(); + private String name; - public static Hall create(String name) { - return new Hall(name); + private Long registantId; + + public Hall(String name, Long registantId) { + this.name = name; + this.registantId = registantId; + } + + public static Hall create(String name, Long 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..f6ffa09 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.domain.hall; + +import java.util.List; + +public record HallRegisterRequest(String hallName, List sectionRegisterRequests) { +} + 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..7788f5f --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java @@ -0,0 +1,4 @@ +package org.mandarin.booking.domain.hall; + +public record SeatRegisterRequest(String rowNumber, 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..04be551 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.domain.hall; + +import java.util.List; + +public record SectionRegisterRequest(String sectionName, List seats) { + +} From b15ee01cc56ead8d023374959e8a058bad952872 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 19:58:51 +0900 Subject: [PATCH 05/25] add authorization for hall registration API and update tests for access control --- ...nAuthorizationRequestMatcherConfigurer.java | 1 + .../booking/webapi/hall/POST_specs.java | 18 ++++++++++++++++++ docs/specs/api/hall_register.md | 3 +-- .../booking/adapter/ResponseWrapper.java | 6 ++++-- 4 files changed, 24 insertions(+), 4 deletions(-) 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/test/java/org/mandarin/booking/webapi/hall/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/hall/POST_specs.java index 965421e..88871b9 100644 --- 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 @@ -2,6 +2,8 @@ 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.FORBIDDEN; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import java.util.List; @@ -54,4 +56,20 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 3c6e02c..6b855e9 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -64,7 +64,7 @@ ### 테스트 -- [ ] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 +- [x] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 - [ ] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 - [ ] 토큰 없거나 무효하면 UNAUTHORIZED을 반환한다 - [ ] name이 비어있으면 BAD_REQUEST을 반환한다 @@ -79,7 +79,6 @@ - [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 -- [ ] 요청자가 ADMIN 권한이 없는 경우 ACCESS_DENIED를 반환한다 - [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 - [ ] sections가 비어있으면 BAD_REQUEST를 반환한다 - [ ] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 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); } } From 2fb6e9e33de8533313b9458578bc9c70038192cc Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:00:56 +0900 Subject: [PATCH 06/25] update hall registration API tests to include checks for invalid tokens and access control --- .../booking/webapi/hall/POST_specs.java | 22 +++++++++++++++++++ docs/specs/api/hall_register.md | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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 index 88871b9..b7dfe59 100644 --- 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 @@ -5,6 +5,7 @@ import static org.mandarin.booking.MemberAuthority.USER; 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.DisplayName; @@ -72,4 +73,25 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 6b855e9..0e4c2fe 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -65,8 +65,8 @@ ### 테스트 - [x] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 -- [ ] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 -- [ ] 토큰 없거나 무효하면 UNAUTHORIZED을 반환한다 +- [x] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 +- [x] 토큰이 무효하면 UNAUTHORIZED을 반환한다 - [ ] name이 비어있으면 BAD_REQUEST을 반환한다 - [ ] sections 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] 섹션 name이 비어있으면 BAD_REQUEST을 반환한다 From 354231bc36ad0255231bcda81d724623b4a3bbed Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:03:01 +0900 Subject: [PATCH 07/25] add validation for hall registration request and update tests for empty name --- .../adapter/webapi/HallController.java | 3 ++- .../booking/webapi/hall/POST_specs.java | 21 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/HallRegisterRequest.java | 6 +++++- 4 files changed, 29 insertions(+), 3 deletions(-) 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 index e1529d2..499a1c5 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java @@ -1,5 +1,6 @@ package org.mandarin.booking.adapter.webapi; +import jakarta.validation.Valid; import org.mandarin.booking.domain.hall.HallRegisterRequest; import org.mandarin.booking.domain.hall.HallRegisterResponse; import org.springframework.web.bind.annotation.PostMapping; @@ -11,7 +12,7 @@ @RequestMapping("/api/hall") class HallController { @PostMapping - HallRegisterResponse register(@RequestBody HallRegisterRequest request) { + HallRegisterResponse register(@RequestBody @Valid HallRegisterRequest request) { return null; } } 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 index b7dfe59..34d1fda 100644 --- 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 @@ -3,6 +3,7 @@ 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.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; @@ -94,4 +95,24 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 0e4c2fe..87434e5 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -67,7 +67,7 @@ - [x] ADMIN 권한의 토큰과 유효 본문으로 요청하면 SUCCESS와 hallId를 반환한다 - [x] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 - [x] 토큰이 무효하면 UNAUTHORIZED을 반환한다 -- [ ] name이 비어있으면 BAD_REQUEST을 반환한다 +- [x] name이 비어있으면 BAD_REQUEST을 반환한다 - [ ] sections 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] 섹션 name이 비어있으면 BAD_REQUEST을 반환한다 - [ ] seats 빈 배열이면 BAD_REQUEST을 반환한다 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 index f6ffa09..3d20444 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -1,7 +1,11 @@ package org.mandarin.booking.domain.hall; +import jakarta.validation.constraints.NotBlank; import java.util.List; -public record HallRegisterRequest(String hallName, List sectionRegisterRequests) { +public record HallRegisterRequest( + @NotBlank + String hallName, + List sectionRegisterRequests) { } From 02049e1444d44b1e29eebe57d810698314705224 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:05:05 +0900 Subject: [PATCH 08/25] add validation for empty sections array in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 19 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/HallRegisterRequest.java | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) 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 index 34d1fda..6e295b4 100644 --- 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 @@ -8,6 +8,7 @@ import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,6 +18,7 @@ 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 @@ -115,4 +117,21 @@ class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void sections_빈_배열이면_BAD_REQUEST을_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 87434e5..4452c05 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -68,7 +68,7 @@ - [x] 비ADMIN 토큰으로 요청하면 ACCESS_DENIED을 반환한다 - [x] 토큰이 무효하면 UNAUTHORIZED을 반환한다 - [x] name이 비어있으면 BAD_REQUEST을 반환한다 -- [ ] sections 빈 배열이면 BAD_REQUEST을 반환한다 +- [x] sections 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] 섹션 name이 비어있으면 BAD_REQUEST을 반환한다 - [ ] seats 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] rowNumber 또는 seatNumber가 1 미만이면 BAD_REQUEST을 반환한다 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 index 3d20444..515c3f4 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -1,11 +1,13 @@ package org.mandarin.booking.domain.hall; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record HallRegisterRequest( @NotBlank String hallName, + @Size(min = 1) List sectionRegisterRequests) { } From 5176a6bf9555808c72ba5ff1d1b026e417d7869a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:07:41 +0900 Subject: [PATCH 09/25] add validation for empty section name in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 25 ++++++++++++++++--- docs/specs/api/hall_register.md | 2 +- .../domain/hall/HallRegisterRequest.java | 2 ++ .../domain/hall/SectionRegisterRequest.java | 6 ++++- 4 files changed, 30 insertions(+), 5 deletions(-) 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 index 6e295b4..08526ee 100644 --- 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 @@ -18,7 +18,6 @@ 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 @@ -120,8 +119,7 @@ class POST_specs { @Test void sections_빈_배열이면_BAD_REQUEST을_반환한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TestFixture testFixture + @Autowired IntegrationTestUtils testUtils ) { // Arrange var request = new HallRegisterRequest("name", Collections.emptyList()); @@ -134,4 +132,25 @@ class POST_specs { // 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); + } + } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 4452c05..56a35ce 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -69,7 +69,7 @@ - [x] 토큰이 무효하면 UNAUTHORIZED을 반환한다 - [x] name이 비어있으면 BAD_REQUEST을 반환한다 - [x] sections 빈 배열이면 BAD_REQUEST을 반환한다 -- [ ] 섹션 name이 비어있으면 BAD_REQUEST을 반환한다 +- [x] section name이 비어있으면 BAD_REQUEST을 반환한다 - [ ] seats 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] rowNumber 또는 seatNumber가 1 미만이면 BAD_REQUEST을 반환한다 - [ ] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 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 index 515c3f4..e102c93 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.hall; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.util.List; @@ -8,6 +9,7 @@ public record HallRegisterRequest( @NotBlank String hallName, @Size(min = 1) + @Valid List sectionRegisterRequests) { } 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 index 04be551..2e2f0a3 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -1,7 +1,11 @@ package org.mandarin.booking.domain.hall; +import jakarta.validation.constraints.NotBlank; import java.util.List; -public record SectionRegisterRequest(String sectionName, List seats) { +public record SectionRegisterRequest( + @NotBlank + String sectionName, + List seats) { } From 684e87841da3d5abc1c8c1b4e53eaf2867914f78 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:09:14 +0900 Subject: [PATCH 10/25] add validation for empty seats array in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 17 +++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/SectionRegisterRequest.java | 2 ++ 3 files changed, 20 insertions(+), 1 deletion(-) 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 index 08526ee..d5376df 100644 --- 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 @@ -153,4 +153,21 @@ class POST_specs { 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 56a35ce..4731bfc 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -70,7 +70,7 @@ - [x] name이 비어있으면 BAD_REQUEST을 반환한다 - [x] sections 빈 배열이면 BAD_REQUEST을 반환한다 - [x] section name이 비어있으면 BAD_REQUEST을 반환한다 -- [ ] seats 빈 배열이면 BAD_REQUEST을 반환한다 +- [x] seats 빈 배열이면 BAD_REQUEST을 반환한다 - [ ] rowNumber 또는 seatNumber가 1 미만이면 BAD_REQUEST을 반환한다 - [ ] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 - [ ] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 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 index 2e2f0a3..1201f08 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -1,11 +1,13 @@ package org.mandarin.booking.domain.hall; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record SectionRegisterRequest( @NotBlank String sectionName, + @Size(min = 1) List seats) { } From 3349d1d3a77edb8ccba0b25b1973af1e13af57a0 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:12:55 +0900 Subject: [PATCH 11/25] add validation for rowNumber and seatNumber in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 31 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/SeatRegisterRequest.java | 8 ++++- .../domain/hall/SectionRegisterRequest.java | 2 ++ 4 files changed, 41 insertions(+), 2 deletions(-) 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 index d5376df..bde72f1 100644 --- 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 @@ -12,6 +12,8 @@ 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.mandarin.booking.domain.hall.HallRegisterRequest; import org.mandarin.booking.domain.hall.HallRegisterResponse; import org.mandarin.booking.domain.hall.SeatRegisterRequest; @@ -170,4 +172,33 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 4731bfc..a0b6f7b 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -71,7 +71,7 @@ - [x] sections 빈 배열이면 BAD_REQUEST을 반환한다 - [x] section name이 비어있으면 BAD_REQUEST을 반환한다 - [x] seats 빈 배열이면 BAD_REQUEST을 반환한다 -- [ ] rowNumber 또는 seatNumber가 1 미만이면 BAD_REQUEST을 반환한다 +- [x] rowNumber 또는 seatNumber가 빈 문자인 경우 BAD_REQUEST을 반환한다 - [ ] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 - [ ] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 - [ ] hall을 등록하면 등록한 사용자 정보도 저장된다 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 index 7788f5f..d362092 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SeatRegisterRequest.java @@ -1,4 +1,10 @@ package org.mandarin.booking.domain.hall; -public record SeatRegisterRequest(String rowNumber, String seatNumber) { +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/SectionRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java index 1201f08..2f33c48 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.hall; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.util.List; @@ -8,6 +9,7 @@ public record SectionRegisterRequest( @NotBlank String sectionName, @Size(min = 1) + @Valid List seats) { } From b4b2d2516928ae7ec18ec9358a11928bd7584e89 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:27:14 +0900 Subject: [PATCH 12/25] add validation for duplicate rowNumber and seatNumber combinations in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 21 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/SectionRegisterRequest.java | 9 +++++++- 3 files changed, 30 insertions(+), 2 deletions(-) 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 index bde72f1..eca144a 100644 --- 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 @@ -201,4 +201,25 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index a0b6f7b..cfa6111 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -72,7 +72,7 @@ - [x] section name이 비어있으면 BAD_REQUEST을 반환한다 - [x] seats 빈 배열이면 BAD_REQUEST을 반환한다 - [x] rowNumber 또는 seatNumber가 빈 문자인 경우 BAD_REQUEST을 반환한다 -- [ ] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 +- [x] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 - [ ] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 - [ ] hall을 등록하면 등록한 사용자 정보도 저장된다 - [ ] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 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 index 2f33c48..930dab2 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -1,9 +1,12 @@ 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 @@ -11,5 +14,9 @@ public record SectionRegisterRequest( @Size(min = 1) @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)); + } } From a1cec21d4ac1c01010e138ab9a6156b861a4b0e1 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 1 Oct 2025 20:29:54 +0900 Subject: [PATCH 13/25] add validation for duplicate section names in hall registration request and update tests --- .../booking/webapi/hall/POST_specs.java | 23 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- .../domain/hall/HallRegisterRequest.java | 8 +++++++ 3 files changed, 32 insertions(+), 1 deletion(-) 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 index eca144a..5c0d856 100644 --- 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 @@ -222,4 +222,27 @@ class POST_specs { // 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index cfa6111..28cb00a 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -73,7 +73,7 @@ - [x] seats 빈 배열이면 BAD_REQUEST을 반환한다 - [x] rowNumber 또는 seatNumber가 빈 문자인 경우 BAD_REQUEST을 반환한다 - [x] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 -- [ ] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 +- [x] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 - [ ] hall을 등록하면 등록한 사용자 정보도 저장된다 - [ ] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 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 index e102c93..0dcaf1f 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -1,6 +1,7 @@ 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; @@ -11,5 +12,12 @@ public record HallRegisterRequest( @Size(min = 1) @Valid List sectionRegisterRequests) { + @AssertFalse(message = "Duplicate section names are not allowed") + public boolean hasDuplicateSectionNames() { + return sectionRegisterRequests.stream() + .map(SectionRegisterRequest::sectionName) + .distinct() + .count() != sectionRegisterRequests.size(); + } } From 69974799862ce805ab76e7dd12ccac1e55bd1a99 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:18:24 +0900 Subject: [PATCH 14/25] refactor hall registration to use hallName instead of name and implement hall registration logic --- .../adapter/webapi/HallController.java | 9 ++++--- .../app/hall/HallCommandRepository.java | 16 ++++++++++++ .../booking/app/hall/HallQueryRepository.java | 6 ++++- .../booking/app/hall/HallRegisterer.java | 8 ++++++ .../booking/app/hall/HallRepository.java | 4 +++ .../booking/app/hall/HallService.java | 18 ++++++++++++- .../booking/app/show/ShowQueryRepository.java | 2 +- .../booking/app/show/ShowService.java | 2 +- .../mandarin/booking/utils/TestFixture.java | 8 +++--- .../booking/webapi/hall/POST_specs.java | 25 +++++++++++++++++++ .../booking/webapi/show/showId/GET_specs.java | 2 +- docs/specs/api/hall_register.md | 2 +- .../mandarin/booking/domain/hall/Hall.java | 12 +++++---- 13 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java create mode 100644 application/src/main/java/org/mandarin/booking/app/hall/HallRegisterer.java 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 index 499a1c5..d64268d 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/HallController.java @@ -1,8 +1,10 @@ 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; @@ -10,9 +12,10 @@ @RestController @RequestMapping("/api/hall") -class HallController { +record HallController(HallRegisterer registerer) { @PostMapping - HallRegisterResponse register(@RequestBody @Valid HallRegisterRequest request) { - return null; + 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..c7d5dce 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,12 +3,15 @@ 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) { @@ -21,4 +24,17 @@ public void checkHallExist(Long hallId) { public Hall fetch(Long hallId) { return queryRepository.findById(hallId); } + + @Override + public HallRegisterResponse register(String userId, HallRegisterRequest request) { + if (queryRepository.existsByHallName(request.hallName())) { + throw new HallException("INTERNAL_SERVER_ERROR", "이미 존재하는 공연장 이름입니다."); + } + + 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/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..27d1e83 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 @@ -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/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index ca3bf60..2d773fd 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -87,7 +87,7 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc } public Hall insertDummyHall() { - var hall = Hall.create("hall name", 1L); + var hall = Hall.create("hall name", "userId"); entityManager.persist(hall); return hall; } @@ -191,9 +191,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 index 5c0d856..30c266e 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -245,4 +246,28 @@ class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void hall을_등록하면_등록한_사용자_정보도_저장된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var request = new HallRegisterRequest("name", List.of( + new SectionRegisterRequest("sectionName", List.of( + new SeatRegisterRequest("A", "1") + )) + )); + var member = testFixture.insertDummyMember("test@test.com", "test", List.of(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()); + } } 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 index 28cb00a..0db8e41 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -74,7 +74,7 @@ - [x] rowNumber 또는 seatNumber가 빈 문자인 경우 BAD_REQUEST을 반환한다 - [x] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 - [x] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 -- [ ] hall을 등록하면 등록한 사용자 정보도 저장된다 +- [x] hall을 등록하면 등록한 사용자 정보도 저장된다 - [ ] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 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 5f91fd2..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 @@ -3,6 +3,7 @@ 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; @@ -19,16 +20,17 @@ public class Hall extends AbstractEntity { @OneToMany(mappedBy = "hall", cascade = ALL, fetch = LAZY) private List
sections = new ArrayList<>(); - private String name; + @Column(unique = true) + private String hallName; - private Long registantId; + private String registantId; - public Hall(String name, Long registantId) { - this.name = name; + public Hall(String hallName, String registantId) { + this.hallName = hallName; this.registantId = registantId; } - public static Hall create(String name, Long registantId) { + public static Hall create(String name, String registantId) { return new Hall(name, registantId); } } From aa6c2d7cc18eea108da0b025c65e3d1c46b9e7fd Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:21:07 +0900 Subject: [PATCH 15/25] add test for handling duplicate hall names returning INTERNAL_SERVER_ERROR --- .../booking/webapi/hall/POST_specs.java | 30 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) 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 index 30c266e..d315477 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -270,4 +271,33 @@ class POST_specs { 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 0db8e41..65b62e6 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -75,7 +75,7 @@ - [x] 동일 섹션 내 rowNumber와 seatNumber의 조합이 중복이면 BAD_REQUEST을 반환한다 - [x] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 - [x] hall을 등록하면 등록한 사용자 정보도 저장된다 -- [ ] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 +- [x] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 From 78f18b83b563c6cb7583d0d29cfc099f6cd04aa6 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:24:57 +0900 Subject: [PATCH 16/25] update section name validation to return BAD_REQUEST for duplicates and add corresponding test --- .../booking/webapi/hall/POST_specs.java | 25 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) 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 index d315477..ddbc39b 100644 --- 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 @@ -300,4 +300,29 @@ class POST_specs { // 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"); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 65b62e6..5458330 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -76,7 +76,7 @@ - [x] 섹션 이름이 중복되면 BAD_REQUEST을 반환한다 - [x] hall을 등록하면 등록한 사용자 정보도 저장된다 - [x] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 -- [ ] section name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 +- [x] section name이 중복되면 BAD_REQUEST을 반환한다 - [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 - [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 From 0917f3e9eb726443d868264fae43b7b112b00a24 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:27:17 +0900 Subject: [PATCH 17/25] add test for duplicate seat requests within the same section returning BAD_REQUEST --- .../booking/webapi/hall/POST_specs.java | 21 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) 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 index ddbc39b..38b4382 100644 --- 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 @@ -325,4 +325,25 @@ class POST_specs { 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); + } } diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 5458330..c85ae63 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -77,7 +77,7 @@ - [x] hall을 등록하면 등록한 사용자 정보도 저장된다 - [x] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [x] section name이 중복되면 BAD_REQUEST을 반환한다 -- [ ] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 +- [x] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 - [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 - [ ] sections가 비어있으면 BAD_REQUEST를 반환한다 From 30f5c422c0908fe80034934f3af1a422f143c9f3 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:32:13 +0900 Subject: [PATCH 18/25] add test to ensure hall is not saved when sub-information is invalid --- .../booking/webapi/hall/POST_specs.java | 40 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) 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 index 38b4382..c7964df 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -346,4 +347,43 @@ class POST_specs { // 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(); + } + + 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/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index c85ae63..91080af 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -78,7 +78,7 @@ - [x] hall name이 중복되면 INTERNAL_SERVER_ERROR을 반환한다 - [x] section name이 중복되면 BAD_REQUEST을 반환한다 - [x] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 -- [ ] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 +- [x] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 - [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 - [ ] sections가 비어있으면 BAD_REQUEST를 반환한다 - [ ] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 From 64157c1e2ff9b2eac067c0c8624ff48813251a58 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:35:15 +0900 Subject: [PATCH 19/25] add validation to ensure at least one section and one seat are provided in hall registration --- .../booking/webapi/hall/POST_specs.java | 17 +++++++++++++++++ docs/specs/api/hall_register.md | 3 +-- .../domain/hall/HallRegisterRequest.java | 2 +- .../domain/hall/SectionRegisterRequest.java | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) 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 index c7964df..1f622cc 100644 --- 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 @@ -365,6 +365,23 @@ class POST_specs { 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"); + } + private static List blankNameRequests() { return List.of( new HallRegisterRequest("name", List.of( diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 91080af..629b72b 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -79,6 +79,5 @@ - [x] section name이 중복되면 BAD_REQUEST을 반환한다 - [x] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [x] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 -- [ ] 하나의 hall에 동일한 section name을 등록 요청하면 BAD_REQUEST가 발생한다 -- [ ] sections가 비어있으면 BAD_REQUEST를 반환한다 +- [x] sections가 비어있으면 BAD_REQUEST를 반환한다 - [ ] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 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 index 0dcaf1f..e9ea593 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallRegisterRequest.java @@ -9,7 +9,7 @@ public record HallRegisterRequest( @NotBlank String hallName, - @Size(min = 1) + @Size(min = 1, message = "At least one section is required") @Valid List sectionRegisterRequests) { @AssertFalse(message = "Duplicate section names are not allowed") 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 index 930dab2..8162ec4 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/SectionRegisterRequest.java @@ -11,7 +11,7 @@ public record SectionRegisterRequest( @NotBlank String sectionName, - @Size(min = 1) + @Size(min = 1, message = "At least one seat is required") @Valid List seats) { @AssertFalse(message = "Duplicate seats are not allowed") From a0c6b5dddbfb993102014887810cd38269ed3107 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:37:00 +0900 Subject: [PATCH 20/25] add test to ensure BAD_REQUEST is returned when section seats are empty --- .../booking/webapi/hall/POST_specs.java | 20 +++++++++++++++++++ docs/specs/api/hall_register.md | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) 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 index 1f622cc..6b7cdc8 100644 --- 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 @@ -382,6 +382,26 @@ class POST_specs { 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( diff --git a/docs/specs/api/hall_register.md b/docs/specs/api/hall_register.md index 629b72b..ce95b97 100644 --- a/docs/specs/api/hall_register.md +++ b/docs/specs/api/hall_register.md @@ -80,4 +80,4 @@ - [x] 동일한 section 내에 중복된 죄석을 요청하면 BAD_REQUEST를 반환한다 - [x] hall 하위 정보가 잘못된 경우 hall도 저장되지 않는다 - [x] sections가 비어있으면 BAD_REQUEST를 반환한다 -- [ ] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 +- [x] section의 seats가 비어있으면 BAD_REQUEST를 반환한다 From 91f643241910de09ac2f478fb68ae21a15638d61 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:43:41 +0900 Subject: [PATCH 21/25] rename hall existence checks for clarity and consistency --- .../mandarin/booking/app/hall/HallService.java | 17 +++++++++++------ .../booking/app/hall/HallValidator.java | 4 +++- .../mandarin/booking/app/show/ShowService.java | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) 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 c7d5dce..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 @@ -14,22 +14,27 @@ class HallService implements HallValidator, HallFetcher, HallRegisterer { 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) { - if (queryRepository.existsByHallName(request.hallName())) { - throw new HallException("INTERNAL_SERVER_ERROR", "이미 존재하는 공연장 이름입니다."); - } + checkHallExistByHallName(request.hallName()); var hall = Hall.create(request.hallName(), userId); 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/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 27d1e83..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); From 927439c4f47526202773ebc5ed21ac9bc810be5c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 13:58:00 +0900 Subject: [PATCH 22/25] add HallFixture for generating random hall names and refactor MemberService for validation --- .../app/member/MemberRegisterValidator.java | 23 ---------------- .../booking/app/member/MemberService.java | 23 +++++++++++++--- .../booking/app/member/MemberValidator.java | 7 +++++ .../mandarin/booking/utils/HallFixture.java | 9 +++++++ .../mandarin/booking/utils/TestFixture.java | 27 ++++++++++++------- .../booking/webapi/hall/POST_specs.java | 5 ++-- .../booking/webapi/show/POST_specs.java | 12 ++++----- 7 files changed, 61 insertions(+), 45 deletions(-) delete mode 100644 application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java create mode 100644 application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java create mode 100644 application/src/test/java/org/mandarin/booking/utils/HallFixture.java 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/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/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 2d773fd..cbe0b25 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; @@ -68,8 +70,13 @@ public Member insertDummyMember() { return this.insertDummyMember(generateUserId(), generatePassword()); } + public Member insertDummyMember(MemberAuthority memberAuthority) { + return this.insertDummyMember(generateUserId(), generatePassword(), List.of(memberAuthority)); + } + public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { - var hall = insertDummyHall(); + var member = insertDummyMember(generateUserId(), generatePassword(), List.of(ADMIN)); + var hall = insertDummyHall(member.getUserId()); var command = ShowCreateCommand.from( new ShowRegisterRequest( hall.getId(), @@ -86,14 +93,14 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc return showInsert(show); } - public Hall insertDummyHall() { - var hall = Hall.create("hall name", "userId"); + public Hall insertDummyHall(String userId) { + var hall = Hall.create(generateHallName(), userId); entityManager.persist(hall); return hall; } public Show generateShow(int scheduleCount) { - var hall = insertDummyHall(); + var hall = insertDummyHall(generateUserId()); var show = generateShow(hall.getId()); for (int i = 0; i < scheduleCount; i++) { @@ -110,27 +117,27 @@ public Show generateShow(int scheduleCount) { } public 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 +152,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 +172,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), 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 index 6b7cdc8..11019ff 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -255,12 +256,12 @@ class POST_specs { @Autowired TestFixture testFixture ) { // Arrange - var request = new HallRegisterRequest("name", List.of( + var request = new HallRegisterRequest(generateHallName(), List.of( new SectionRegisterRequest("sectionName", List.of( new SeatRegisterRequest("A", "1") )) )); - var member = testFixture.insertDummyMember("test@test.com", "test", List.of(ADMIN)); + var member = testFixture.insertDummyMember(ADMIN); var authToken = testUtils.getAuthToken(member); // Act 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..27f7006 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 @@ -55,7 +55,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 +75,7 @@ static List nullOrBlankElementRequests() { @Autowired TestFixture testFixture ) { // Arrange - var hallId = testFixture.insertDummyHall().getId(); + var hallId = testFixture.insertDummyHall("userId").getId(); var request = validShowRegisterRequest(hallId); // Act @@ -117,7 +117,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 +148,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 +170,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 +204,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 From 7e224b0095d7090b9f49188a757184e8da741a1e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 14:24:08 +0900 Subject: [PATCH 23/25] enhance integration test utilities and add response wrapper tests --- .../booking/utils/IntegrationTestUtils.java | 3 +- .../mandarin/booking/utils/TestConfig.java | 17 ++++ .../mandarin/booking/utils/TestFixture.java | 18 +++- .../booking/adapter/ResponseWrapperTest.java | 83 +++++++++++++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 internal/src/test/java/org/mandarin/booking/adapter/ResponseWrapperTest.java 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..8274d8d 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 fastTestPasswordEncoder() { + 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 cbe0b25..e8172c1 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -34,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(), @@ -66,10 +80,6 @@ public Member insertDummyMember(String userId, String nickName, List 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"); + } + } +} From 34174e6efe61ffbfdcb960853142ae8367c927d5 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 14:25:59 +0900 Subject: [PATCH 24/25] rename fastTestPasswordEncoder to testPasswordEncoder for clarity --- .../src/test/java/org/mandarin/booking/utils/TestConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8274d8d..d637335 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -30,7 +30,7 @@ public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEn @Bean @Primary - public SecurePasswordEncoder fastTestPasswordEncoder() { + public SecurePasswordEncoder testPasswordEncoder() { return new SecurePasswordEncoder() { @Override public String encode(String password) { From 9af037406e38dff0f59e84afd553265eb5682e2a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 2 Oct 2025 14:39:56 +0900 Subject: [PATCH 25/25] refactor documentation for clarity on ports, adapters, and security components --- .../booking/webapi/show/POST_specs.java | 24 ++++++++++++++++++- .../webapi/show/schedule/POST_specs.java | 2 +- docs/specs/api/show_register.md | 5 ++-- docs/specs/api/show_schedule_register.md | 2 +- docs/specs/policy/application.md | 20 +++++++++------- docs/specs/policy/authentication.md | 1 + docs/specs/policy/authorization.md | 7 +++++- docs/specs/policy/test.md | 4 ++-- 8 files changed, 48 insertions(+), 17 deletions(-) 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 27f7006..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; @@ -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 ) { @@ -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/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 b424573..9dab404 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -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/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 7abfa0b..3f934fa 100644 --- a/docs/specs/policy/authentication.md +++ b/docs/specs/policy/authentication.md @@ -87,6 +87,7 @@ - `POST /api/auth/reissue` - `GET /api/show`, `GET /api/show/*` - 인증/권한 필요: (근거: ApplicationAuthorizationRequestMatcherConfigurer) + - `POST /api/hall` → `ROLE_ADMIN` - `POST /api/show` → `ROLE_ADMIN` - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` - 그 외 `/api/**` → 인증 필요 diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md index 5b46aee..916df94 100644 --- a/docs/specs/policy/authorization.md +++ b/docs/specs/policy/authorization.md @@ -33,6 +33,7 @@ - `GET /api/show`, `GET /api/show/*` - 권한 필요(hasAuthority): + - `POST /api/hall` → `ROLE_ADMIN` - `POST /api/show` → `ROLE_ADMIN` - `POST /api/show/schedule` → `ROLE_DISTRIBUTOR` @@ -48,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 42250b1..b1d0fc3 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -41,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']` 권장 규칙: @@ -98,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에서만 접근 가능.