From 89852991e133e75c3a8f40ea6fd82b58368c2613 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Mon, 23 Feb 2026 21:28:39 +0900 Subject: [PATCH 01/68] =?UTF-8?q?chore:=20Claude=20Code=20rules=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98,=20=ED=85=8C=EC=8A=A4=ED=8A=B8,=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D,=20=EA=B8=B0=EC=88=A0=EC=8A=A4=ED=83=9D,=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=84=A4=EC=A0=95,=20=EB=AA=85=EB=A0=B9=EC=96=B4,=20?= =?UTF-8?q?=EC=9D=98=EC=82=AC=EA=B2=B0=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/testing.md | 11 +++ .claude/rules/core/decision-making.md | 10 +++ .claude/rules/project/archtecture.md | 100 ++++++++++++++++++++++++ .claude/rules/project/authentication.md | 6 ++ .claude/rules/project/commands.md | 16 ++++ .claude/rules/project/configuration.md | 19 +++++ .claude/rules/project/tech-stack.md | 9 +++ 7 files changed, 171 insertions(+) create mode 100644 .claude/rules/conventions/testing.md create mode 100644 .claude/rules/core/decision-making.md create mode 100644 .claude/rules/project/archtecture.md create mode 100644 .claude/rules/project/authentication.md create mode 100644 .claude/rules/project/commands.md create mode 100644 .claude/rules/project/configuration.md create mode 100644 .claude/rules/project/tech-stack.md diff --git a/.claude/rules/conventions/testing.md b/.claude/rules/conventions/testing.md new file mode 100644 index 000000000..4ff9ce1fa --- /dev/null +++ b/.claude/rules/conventions/testing.md @@ -0,0 +1,11 @@ +# 테스트 + +## 테스트 분류 +- 단위 테스트: 도메인 모델 검증 +- 통합 테스트: Service + Repository (TestContainers) +- E2E 테스트: API 엔드포인트 전체 흐름 + +## 테스트 유틸리티 +- `DatabaseCleanUp`: 테스트마다 테이블 정리 +- TestContainers: 실제 MySQL, Redis, Kafka 인스턴스 제공 +- 테스트 픽스처: `supports/` 모듈 \ No newline at end of file diff --git a/.claude/rules/core/decision-making.md b/.claude/rules/core/decision-making.md new file mode 100644 index 000000000..2022d563c --- /dev/null +++ b/.claude/rules/core/decision-making.md @@ -0,0 +1,10 @@ +# 의사결정 규칙 + +- AI는 제안하고, 최종 결정은 개발자가 한다 +- 요청하지 않은 기능 구현, 테스트 삭제, 반복적 동작 시 즉시 중단하고 보고 +- 누락, 불명확, 다수의 유효한 접근법이 있으면 스스로 결정하지 말고 반드시 물어볼 것 +- 되돌리기 어려운 결정(아키텍처, DB 스키마, 외부 API 설계)은 반드시 확인받을 것 +- 빠른 선택은 AskUserQuestion, 깊은 논의가 필요하면 대화로 풀 것 + +## 참고 스킬 +- 의사결정 판단 기준 → decision-guide \ No newline at end of file diff --git a/.claude/rules/project/archtecture.md b/.claude/rules/project/archtecture.md new file mode 100644 index 000000000..e55782213 --- /dev/null +++ b/.claude/rules/project/archtecture.md @@ -0,0 +1,100 @@ +# 아키텍처 + +## 패키지 구조 (DDD) +``` +com.loopers/ +├── interfaces/ # REST 컨트롤러, Request/Response DTO +├── application/ # Facade (오케스트레이션, 트랜잭션 경계), Info DTO +├── domain/ # Entity, Service, Domain Service, Repository 인터페이스, VO +├── infrastructure/ # Repository 구현체, 외부 어댑터 +└── support/ # 횡단 관심사 (에러, 유틸, 글로벌 핸들러) +``` + +## 의존성 방향 +``` +interfaces → application → domain ← infrastructure +``` +- domain은 infrastructure를 알지 않음 +- 외부 의존성은 인터페이스로 추상화 +- **도메인 간 의존 규칙**: Service는 자기 도메인만 접근, 크로스 도메인 협력은 반드시 Facade에서 + +## 핵심 패턴 + +- **Rich Domain Model**: 비즈니스 로직과 도메인 불변식은 Entity에 +- **Service**: 자기 도메인의 연산 캡슐화 (Repository 접근 + Entity/Domain Service 위임) +- **Facade**: 여러 도메인 Service 간 오케스트레이션, 트랜잭션 경계 +- **Repository 패턴**: 인터페이스는 `domain/`, 구현체는 `infrastructure/ +- **DTO**: Java record 사용 (불변 보장) +- **Soft Delete**: `deletedAt` 필드 사용 + +## Base Entity + +- 수정/삭제가 있는 엔티티: `BaseEntity` 상속 (`createdAt`, `updatedAt`, `deletedAt`) +- 생성만 있는 엔티티 (좋아요, 로그 등): `createdAt`만 포함 +- 요구사항에 삭제가 없더라도 수정이 있으면 `BaseEntity` 사용 권장 (삭제 요구는 나중에 추가될 확률 높음) + +## 계층별 역할 + +### Controller (interfaces) +- HTTP 요청/응답 변환만 담당 +- Request에서 원시값 추출하여 Facade에 전달 +- try-catch 금지 (글로벌 핸들러가 처리) +- `@Valid`로 Request DTO 입력값 형식 검증 +- API별 enum은 Request/Response DTO 내부에 inner enum으로 정의 + +### Facade (application) +- 여러 도메인 Service 호출 오케스트레이션 +- 트랜잭션 경계 (`@Transactional`) +- Domain Entity → Info DTO 변환 +- 다른 도메인의 **Service만** 호출 (Repository 직접 호출 금지) + +### Service (domain) — 자기 도메인의 연산 캡슐화 +- **조회 메서드**: 비즈니스 의미를 가진 이름으로 제공 (`getActiveUser`, `getActiveProduct` 등) +- **명령 메서드**: 조회+판단+실행을 하나의 비즈니스 연산으로 캡슐화 +- 자기 도메인의 Repository + Domain Service만 사용 +- **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) +- Facade에 도메인 내부 구조(Optional, 상태값, Entity 컬렉션) 노출 최소화 +- `@Transactional` 사용하지 않음 (트랜잭션은 Facade 책임) + + +### Domain Service (domain) — 필요할 때만 생성 +- 단일 Entity로 해결 안 되는 비즈니스 로직 +- 여러 Entity/VO 조합이 필요한 계산, 검증, 결정 +- **Repository 의존 없음** (순수 비즈니스 로직만) +- 네이밍: `-Calculator`, `-Validator`, `-Resolver`, `-Policy` + +### Entity (domain) — 비즈니스 로직의 중심 +- 정적 팩토리 메서드로 생성 (`create()`) +- 자기 데이터의 검증, 상태 전이, 계산 +- setter 대신 의미 있는 메서드명 (`changeToFailed()`, `deductStock()`) +- 생성자에서 필수 불변 조건(invariant) 검증 + +### Repository (domain → infrastructure) +- 인터페이스는 `domain/` 패키지에 정의 +- 구현체는 `infrastructure/` 패키지에 배치 + +### 트랜잭션 전략 +- **Service**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 + - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 +- **Facade**: `@Transactional`로 여러 Service를 하나의 트랜잭션으로 묶음 + - Facade가 있으면 Service의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) + +# 검증 전략 + +### 검증 위치와 역할 + +| 위치 | 역할 | 예시 | +|---|---|---| +| `@Valid` (DTO) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | +| Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | +| Service | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | + +- `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) +- Entity가 검증의 최종 방어선 — DTO 검증이 빠져도 Entity에서 반드시 잡아야 함 + +## 예외 처리 +- 비즈니스 예외는 `CoreException`으로 통일 +- `CoreException(ErrorType, message)` 형태로 사용 +- 글로벌 핸들러(`@RestControllerAdvice`)에서 일괄 처리 +- Controller에서 try-catch 금지 +- ErrorType은 HTTP 상태코드와 매핑되는 enum으로 관리 (`BAD_REQUEST`, `NOT_FOUND`, `FORBIDDEN` 등) \ No newline at end of file diff --git a/.claude/rules/project/authentication.md b/.claude/rules/project/authentication.md new file mode 100644 index 000000000..7e924e20f --- /dev/null +++ b/.claude/rules/project/authentication.md @@ -0,0 +1,6 @@ +# 인증 + +## 인증 유형 +- **불필요**: 인증 없이 접근 가능 +- **User**: 일반 사용자 인증 — `X-Loopers-LoginId` + `X-Loopers-LoginPw` 헤더 +- **Admin**: 관리자 인증 — `X-Loopers-Ldap` 헤더 diff --git a/.claude/rules/project/commands.md b/.claude/rules/project/commands.md new file mode 100644 index 000000000..46e3d17e5 --- /dev/null +++ b/.claude/rules/project/commands.md @@ -0,0 +1,16 @@ +# 명령어 + +## 빌드 +./gradlew build +./gradlew build -x test +./gradlew clean build + +## 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun +./gradlew :apps:commerce-streamer:bootRun + +## 테스트 +./gradlew test +./gradlew :apps:commerce-api:test +./gradlew test jacocoTestReport \ No newline at end of file diff --git a/.claude/rules/project/configuration.md b/.claude/rules/project/configuration.md new file mode 100644 index 000000000..8e7d575c2 --- /dev/null +++ b/.claude/rules/project/configuration.md @@ -0,0 +1,19 @@ +# 환경 설정 + +## 프로파일 +- `local`: Docker 기반 로컬 개발 +- `dev`, `qa`, `prd`: 환경별 설정 + +## 포트 +- 애플리케이션: 8080 +- Actuator/모니터링: 8081 + +## Docker 서비스 (로컬) +```bash +docker-compose -f docker/docker-compose.yml up -d +``` + +- MySQL: localhost:3306 +- Redis Master: localhost:6379 +- Redis Replica: localhost:6380 +- Kafka: localhost:19092 \ No newline at end of file diff --git a/.claude/rules/project/tech-stack.md b/.claude/rules/project/tech-stack.md new file mode 100644 index 000000000..a7d3e06c3 --- /dev/null +++ b/.claude/rules/project/tech-stack.md @@ -0,0 +1,9 @@ +# 기술 스택 + +- **Java**: 21 +- **Spring Boot**: 3.4.4 +- **Build System**: Gradle (Kotlin DSL) +- **Database**: MySQL 8.0 with Spring Data JPA + QueryDSL +- **Cache**: Redis (Master-Replica) +- **Messaging**: Apache Kafka +- **Testing**: JUnit 5, TestContainers, Mockito \ No newline at end of file From 66969535e447f49d3b330d37353dede3cfe1993a Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Mon, 23 Feb 2026 21:29:58 +0900 Subject: [PATCH 02/68] =?UTF-8?q?chore:=20Claude=20Code=20=EC=8A=A4?= =?UTF-8?q?=ED=82=AC=20=EC=B6=94=EA=B0=80=20(decision-guide,=20rule-manage?= =?UTF-8?q?,=20skill-create,=20skill-modify,=20skill-validate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/decision-guide/SKILL.md | 90 +++++ .../references/question-patterns.md | 126 +++++++ .claude/skills/rule-manage/SKILL.md | 125 +++++++ .../rule-manage/references/rules-spec.md | 106 ++++++ .claude/skills/skill-create/SKILL.md | 129 +++++++ .../skill-create/assets/templates/basic.md | 35 ++ .../assets/templates/mcp-enhanced.md | 57 +++ .../skill-create/assets/templates/subagent.md | 34 ++ .../references/description-guide.md | 85 +++++ .../references/frontmatter-spec.md | 95 +++++ .claude/skills/skill-modify/SKILL.md | 161 +++++++++ .../references/modification-patterns.md | 230 ++++++++++++ .claude/skills/skill-validate/SKILL.md | 145 ++++++++ .../references/quality-criteria.md | 132 +++++++ .../skills/skill-validate/scripts/validate.sh | 339 ++++++++++++++++++ 15 files changed, 1889 insertions(+) create mode 100644 .claude/skills/decision-guide/SKILL.md create mode 100644 .claude/skills/decision-guide/references/question-patterns.md create mode 100644 .claude/skills/rule-manage/SKILL.md create mode 100644 .claude/skills/rule-manage/references/rules-spec.md create mode 100644 .claude/skills/skill-create/SKILL.md create mode 100644 .claude/skills/skill-create/assets/templates/basic.md create mode 100644 .claude/skills/skill-create/assets/templates/mcp-enhanced.md create mode 100644 .claude/skills/skill-create/assets/templates/subagent.md create mode 100644 .claude/skills/skill-create/references/description-guide.md create mode 100644 .claude/skills/skill-create/references/frontmatter-spec.md create mode 100644 .claude/skills/skill-modify/SKILL.md create mode 100644 .claude/skills/skill-modify/references/modification-patterns.md create mode 100644 .claude/skills/skill-validate/SKILL.md create mode 100644 .claude/skills/skill-validate/references/quality-criteria.md create mode 100755 .claude/skills/skill-validate/scripts/validate.sh diff --git a/.claude/skills/decision-guide/SKILL.md b/.claude/skills/decision-guide/SKILL.md new file mode 100644 index 000000000..b328052c4 --- /dev/null +++ b/.claude/skills/decision-guide/SKILL.md @@ -0,0 +1,90 @@ +--- +name: decision-guide +description: 기술 의사결정 시 질문 방식을 판단합니다. 아키텍처 설계, 트레이드오프 분석, 기술 선택, DB 스키마 변경, 외부 API 설계 등 되돌리기 어려운 결정이 필요한 상황에서 사용합니다. 단순 구현이나 버그 수정에는 사용하지 마세요. +user-invocable: false +--- + +# Decision Guide + +기술 의사결정이 필요한 상황에서 질문 방식을 판단하고, 적절한 형태로 사용자에게 확인을 받습니다. + +## 판단 흐름 + +### 1단계: 질문이 필요한가? + +다음 중 하나라도 해당하면 **반드시 질문**: +- 유효한 접근법이 2개 이상 존재 +- 요구사항에 누락되거나 불명확한 부분이 있음 +- 되돌리기 어려운 결정 (DB 스키마, 외부 API, 아키텍처) +- 새로운 외부 의존성 추가 +- 보안/인증 관련 결정 + +다음에 해당하면 **질문 없이 진행**: +- 단일 접근법만 존재 (컴파일 에러 수정, 오타 수정) +- 되돌리기 쉬운 작은 결정 +- 코드베이스에 이미 확립된 패턴을 따르는 경우 + +### 2단계: 어떻게 질문할 것인가? + +**AskUserQuestion 사용 조건** — 아래 3가지를 모두 충족할 때: +- 선택지가 2-4개로 명확히 나뉨 +- 각 선택지의 트레이드오프를 한 줄로 설명 가능 +- 선택 후 바로 다음 단계로 넘어갈 수 있음 + +**대화형 논의 조건** — 위 조건 중 하나라도 미충족: +- 선택지가 명확히 나뉘지 않음 +- 트레이드오프가 복잡해서 맥락 설명이 필요 +- 여러 결정이 서로 얽혀있음 +- 사용자의 도메인 지식이 답에 영향을 줌 + +상세 예시와 대화 구조는 [references/question-patterns.md](references/question-patterns.md) 참고. + +### 3단계: 질문 실행 + +**AskUserQuestion일 때:** +- 각 선택지에 한 줄짜리 트레이드오프 포함 +- 추천 선택지가 있으면 이유와 함께 표시 +- 선택 결과를 받으면 바로 구현 진행 + +**대화형 논의일 때:** +1. 상황 정리 — 현재 상태, 제약 조건, 영향 범위 +2. 선택지와 트레이드오프 — 각 방향의 장단점, 예상 작업량 +3. 추천과 근거 — 추천 방향 제시 + 확인이 필요한 후속 질문 +4. 사용자 답변 기반으로 구체화 + +**논의 시 원칙:** +- 추천 없이 선택지만 나열하지 않기 +- 한 번에 3개 이상의 질문을 던지지 않기 +- 선택지를 억지로 AskUserQuestion에 끼워맞추지 않기 + +## 예시 + +### 예시 1: AskUserQuestion이 적합한 경우 + +상황: 새 HTTP 클라이언트 도입 필요 + +판단: 선택지 3개 (RestTemplate / WebClient / Feign), 각각 한 줄로 트레이드오프 설명 가능, 선택 후 바로 구현 가능 +→ AskUserQuestion 사용 + +### 예시 2: 대화형 논의가 적합한 경우 + +상황: 주문-결제 트랜잭션 경계 설계 + +판단: 이벤트 기반 / 2PC / Saga 등 방향은 있지만, 허용 가능한 불일치 시간, 보상 실패 시 대응 등 사용자 도메인 지식이 필요 +→ 대화형 논의로 상황 정리 → 트레이드오프 → 추천 → 후속 질문 + +### 예시 3: 질문이 불필요한 경우 + +상황: NullPointerException 수정, 기존 패턴과 동일한 CRUD 엔드포인트 추가 +→ 질문 없이 진행 + +## 트러블슈팅 (잘못된 판단 패턴) + +### 복잡한 결정을 억지로 선택지에 끼워맞추기 +트랜잭션 설계처럼 맥락 없이 선택할 수 없는 문제를 AskUserQuestion 3개 선택지로 던지면, 사용자가 충분한 정보 없이 결정하게 됨. 대화로 풀어야 할 문제. + +### 사소한 결정을 매번 물어보기 +변수명, 로그 메시지, import 순서 같은 되돌리기 쉬운 결정까지 질문하면 작업 흐름이 끊김. 코드베이스 패턴을 따라 진행. + +### 추천 없이 선택지만 나열하기 +"A, B, C 중 뭘 하시겠어요?"만 던지면 의사결정 부담을 사용자에게 전가하는 것. 반드시 추천과 근거를 함께 제시. diff --git a/.claude/skills/decision-guide/references/question-patterns.md b/.claude/skills/decision-guide/references/question-patterns.md new file mode 100644 index 000000000..3ada6a0fc --- /dev/null +++ b/.claude/skills/decision-guide/references/question-patterns.md @@ -0,0 +1,126 @@ +# 질문 방식 판단 기준 + +## AskUserQuestion (구조화 질문) + +### 적합한 상황 +- 선택지가 2-4개로 명확히 나뉨 +- 각 선택지의 트레이드오프를 한 줄로 설명 가능 +- 선택 후 바로 구현으로 넘어갈 수 있음 +- 선택지 간 조합이 불필요 (독립적 선택) + +### 예시 + +#### 라이브러리 선택 +``` +Q: HTTP 클라이언트를 어떤 걸로 쓸까요? +1. RestTemplate — 레거시 호환, 팀에 익숙함 +2. WebClient — 리액티브 지원, 논블로킹 +3. Feign — 선언적, 마이크로서비스 간 통신에 적합 +``` + +#### 구현 방식 분기 +``` +Q: 캐시 무효화를 어떤 전략으로 가져갈까요? +1. TTL 기반 — 단순, 일정 시간 후 자동 만료 +2. 이벤트 기반 — 정확, 데이터 변경 시 즉시 무효화 +3. 하이브리드 — TTL + 이벤트, 복잡하지만 안전 +``` + +#### 네이밍/컨벤션 결정 +``` +Q: API 응답 래퍼 클래스 이름은? +1. ApiResponse +2. CommonResponse +3. ResultWrapper +``` + +### 작성 원칙 +- 각 선택지에 한 줄짜리 트레이드오프 명시 +- 추천 선택지가 있으면 이유와 함께 표시 +- "기타 (직접 입력)" 선택지는 넣지 않음 — 자유 입력이 필요하면 대화형이 맞음 + +--- + +## 대화형 논의 (자유 대화) + +### 적합한 상황 +- 선택지를 명확히 나눌 수 없음 +- 트레이드오프가 복잡해서 맥락 설명이 필요 +- 여러 결정이 서로 얽혀있어 순차적 탐색이 필요 +- 사용자의 도메인 지식이 필요한 판단 +- 정답이 없고 "덜 나쁜 선택"을 골라야 하는 상황 + +### 대화 구조 + +#### 1단계: 상황 정리 +현재 코드 상태, 제약 조건, 영향 범위를 먼저 정리해서 보여준다. + +``` +현재 OrderService가 트랜잭션 안에서 결제 API를 호출하고 있습니다. +이 구조의 문제: +- 결제 API 타임아웃 시 DB 트랜잭션도 함께 롤백됨 +- 주문 데이터 유실 가능성 있음 +- 결제는 성공했는데 주문이 실패하는 불일치 발생 가능 + +영향 범위: OrderService, PaymentService, 관련 테스트 12개 +``` + +#### 2단계: 선택지와 트레이드오프 제시 +각 방향의 장단점을 구체적으로 설명한다. + +``` +방향 A: 트랜잭션 분리 + 이벤트 기반 보상 +- 장점: 결제/주문 독립, 부분 실패 허용 +- 단점: 이벤트 인프라 필요, 최종 일관성 복잡도 +- 작업량: 중 (3-5일) + +방향 B: 2PC (Two-Phase Commit) +- 장점: 강한 일관성 +- 단점: 성능 저하, 결제 API가 2PC 미지원 가능 +- 작업량: 대 (5-7일) + +방향 C: 보상 트랜잭션 (Saga) +- 장점: 마이크로서비스 표준 패턴 +- 단점: 보상 로직 복잡, 테스트 어려움 +- 작업량: 대 (5-7일) +``` + +#### 3단계: 추천과 근거 +시니어 관점에서 추천하되 최종 결정은 맡긴다. + +``` +현재 상황에서는 방향 A를 추천합니다. +이유: 결제 API가 2PC를 지원하지 않고, 현재 인프라에 +이벤트 버스(Kafka)가 이미 있어 추가 비용이 적습니다. + +다만 궁금한 점이 있습니다: +- 주문과 결제 사이에 허용 가능한 불일치 시간이 있나요? +- 보상 트랜잭션 실패 시 수동 개입 프로세스가 있나요? +``` + +#### 4단계: 후속 질문 +사용자 답변을 기반으로 구체화한다. + +### 금지 사항 +- 선택지를 억지로 AskUserQuestion에 끼워맞추지 않기 +- "아무거나 괜찮습니다"라고 말하지 않기 +- 추천 없이 선택지만 나열하지 않기 +- 한 번에 3개 이상의 질문을 던지지 않기 + +--- + +## 질문 자체가 불필요한 경우 + +### 물어보지 않고 진행해도 되는 상황 +- 명확한 단일 접근법만 존재 (컴파일 에러 수정, 오타 수정) +- 되돌리기 쉬운 작은 결정 (변수명, 로그 메시지) +- 이미 코드베이스에 확립된 패턴을 따르는 경우 +- CLAUDE.md나 프로젝트 규칙에 답이 있는 경우 + +### 반드시 물어봐야 하는 상황 +- 새로운 외부 의존성 추가 +- 기존 API 계약 변경 +- DB 스키마 변경 (마이그레이션 필요) +- 삭제 또는 되돌리기 어려운 작업 +- 보안/인증 관련 결정 +- 성능에 유의미한 영향을 주는 구조 변경 diff --git a/.claude/skills/rule-manage/SKILL.md b/.claude/skills/rule-manage/SKILL.md new file mode 100644 index 000000000..46c745eaa --- /dev/null +++ b/.claude/skills/rule-manage/SKILL.md @@ -0,0 +1,125 @@ +--- +name: rule-manage +description: Claude Code rules 파일을 생성, 수정, 검증합니다. 사용자가 "rules 만들어줘", "rule 추가해줘", "rules 정리해줘", "CLAUDE.md 분리해줘", "조건부 규칙 추가해줘"를 요청할 때 사용합니다. CLAUDE.md 직접 편집이나 스킬 관련 작업에는 사용하지 마세요. +--- + +# Rule Manage + +`.claude/rules/` 디렉토리의 rule 파일을 생성, 수정, 검증합니다. +공식 규격은 [references/rules-spec.md](references/rules-spec.md) 참고. + +## 워크플로우 + +### 생성 + +1. 사용자에게 확인: + - 규칙 주제 (코드 스타일, 테스트, 보안 등) + - 적용 범위: 글로벌(paths 없음) vs 조건부(paths 있음) + - 저장 위치: 프로젝트(`.claude/rules/`) vs 개인(`~/.claude/rules/`) + +2. 디렉토리 분류 결정: + - `core/` — 프로젝트 무관 행동 규칙 (의사결정, 커뮤니케이션) + - `project/` — 프로젝트 고유 정보 (기술 스택, 빌드, 아키텍처) + - `conventions/` — 코딩 컨벤션 (네이밍, 에러처리, 테스트) + - 기존 디렉토리 구조가 있으면 그 구조를 따름 + +3. 파일 작성: + - 파일명: 케밥케이스, 내용을 설명하는 이름 (`error-handling.md`) + - 글로벌: 프론트매터 없이 바로 `# 제목` + - 조건부: `paths` 프론트매터 + glob 패턴 + +4. 스킬 참조가 필요하면 `## 참고 스킬` 섹션 추가: + ```markdown + ## 참고 스킬 + - {언제} → {스킬명} + ``` + +### 수정 + +1. 대상 rule 파일을 읽고 현재 상태 파악 +2. 수정 사항을 before/after로 제시 +3. 사용자 확인 후 적용 + +수정 유형: +- **CLAUDE.md 분리**: 큰 CLAUDE.md를 rules/로 주제별 분리 +- **paths 추가**: 글로벌 규칙을 조건부로 전환 +- **규칙 병합**: 비슷한 주제의 파일 통합 +- **전역 이동**: 프로젝트 규칙을 `~/.claude/rules/`로 승격 + +### 검증 + +기존 rules 구조를 검증: + +1. **파일 형식 확인** + - `.md` 확장자인가 + - paths 프론트매터가 있으면 YAML 문법이 올바른가 + - glob 패턴이 유효한가 + +2. **구조 확인** + - 파일당 하나의 주제를 다루는가 + - 파일명이 내용을 설명하는가 + - 하위 디렉토리가 논리적으로 분류되어 있는가 + +3. **내용 확인** + - 규칙이 구체적인가 ("적절히" 같은 모호한 표현 없는가) + - 중복 규칙이 없는가 (다른 rule 파일과 겹치지 않는가) + - 참고 스킬이 명시되어 있으면 해당 스킬이 존재하는가 + +## 예시 + +### 예시 1: CLAUDE.md 분리 + +사용자: "CLAUDE.md가 너무 길어, rules로 쪼개줘" + +동작: +1. CLAUDE.md를 읽고 섹션별로 분류 +2. 분리 계획을 제시: + ``` + Technology Stack → project/tech-stack.md + Build Commands → project/build-commands.md + Architecture → project/architecture.md + Coding Conventions → conventions/coding.md + ``` +3. CLAUDE.md에는 Project Overview + Project Structure + Rules Structure만 남김 +4. 사용자 확인 후 파일 생성 + +### 예시 2: 조건부 규칙 생성 + +사용자: "Java 파일에만 적용되는 코딩 규칙 추가해줘" + +동작: +1. paths 패턴 결정: `"src/**/*.java"` +2. 파일 생성: + ```markdown + --- + paths: + - "src/**/*.java" + --- + # Java Coding Rules + - ... + ``` +3. `.claude/rules/conventions/java-coding.md`에 저장 + +### 예시 3: 전역 이동 + +사용자: "이 규칙 모든 프로젝트에서 쓰고 싶어" + +동작: +1. 대상 rule 파일 확인 +2. `~/.claude/rules/`로 복사 +3. 프로젝트 rules에서 제거할지 확인 (제거 시 전역만 적용, 유지 시 프로젝트가 오버라이드) + +## 트러블슈팅 + +### rule이 로드되지 않는 경우 +- 파일 확장자가 `.md`인지 확인 +- `.claude/rules/` 경로가 정확한지 확인 +- `/memory` 명령어로 로드된 메모리 파일 목록 확인 + +### 조건부 규칙이 적용되지 않는 경우 +- paths의 glob 패턴이 대상 파일과 매칭되는지 확인 +- YAML 프론트매터 문법 확인 (탭 대신 스페이스, `---` 구분자) + +### CLAUDE.md와 rules가 충돌하는 경우 +- 동일 우선순위이므로 내용이 모순되지 않게 관리 +- CLAUDE.md에는 개요와 구조만, 상세 규칙은 rules에 위임 diff --git a/.claude/skills/rule-manage/references/rules-spec.md b/.claude/skills/rule-manage/references/rules-spec.md new file mode 100644 index 000000000..b749207d2 --- /dev/null +++ b/.claude/skills/rule-manage/references/rules-spec.md @@ -0,0 +1,106 @@ +# Rules 스펙 (공식 문서 기반) + +Claude Code 공식 문서(code.claude.com/docs/en/memory)에서 추출한 `.claude/rules/` 규격입니다. + +## 기본 구조 + +``` +.claude/rules/ +├── code-style.md # 코드 스타일 +├── testing.md # 테스트 규칙 +├── security.md # 보안 요구사항 +└── frontend/ # 하위 디렉토리 가능 + ├── react.md + └── styles.md +``` + +- `.claude/rules/` 내 모든 `.md` 파일은 **재귀적으로 탐색**하여 자동 로드 +- `.claude/CLAUDE.md`와 **동일한 우선순위**로 프로젝트 메모리에 포함 + +## 파일 형식 + +### paths 없음 (글로벌 규칙) + +모든 파일 작업 시 항상 적용: + +```markdown +# Code Style Guidelines + +- Use 2-space indentation +- Prefer const over let +``` + +### paths 있음 (조건부 규칙) + +특정 파일 패턴에 매칭될 때만 로드: + +```markdown +--- +paths: + - "src/api/**/*.ts" +--- + +# API Development Rules + +- All API endpoints must include input validation +- Use the standard error response format +``` + +## paths 프론트매터 규격 + +### 지원 패턴 + +| 패턴 | 매칭 대상 | +|---|---| +| `**/*.ts` | 모든 디렉토리의 TypeScript 파일 | +| `src/**/*` | src/ 하위 모든 파일 | +| `*.md` | 프로젝트 루트의 Markdown 파일 | +| `src/components/*.tsx` | 특정 디렉토리의 React 컴포넌트 | + +### 복수 패턴 + +```yaml +paths: + - "src/**/*.ts" + - "lib/**/*.ts" + - "tests/**/*.test.ts" +``` + +### Brace Expansion + +```yaml +paths: + - "src/**/*.{ts,tsx}" # .ts와 .tsx 모두 + - "{src,lib}/**/*.ts" # src와 lib 모두 +``` + +## 저장 위치별 동작 + +### 프로젝트 rules +- 위치: `./.claude/rules/*.md` +- 범위: 해당 프로젝트 +- 공유: git으로 팀 공유 + +### 유저 rules +- 위치: `~/.claude/rules/*.md` +- 범위: 모든 프로젝트에 적용 +- 우선순위: 프로젝트 rules보다 **낮음** (프로젝트가 오버라이드) + +## 심링크 지원 + +```bash +# 공유 디렉토리 심링크 +ln -s ~/shared-claude-rules .claude/rules/shared + +# 개별 파일 심링크 +ln -s ~/company-standards/security.md .claude/rules/security.md +``` + +순환 심링크는 감지되어 안전하게 처리됨. + +## Best Practices (공식 권장) + +- **파일당 하나의 주제**: 각 파일이 하나의 토픽만 다룰 것 (testing.md, api-design.md) +- **서술적 파일명**: 파일명만으로 내용을 알 수 있게 +- **조건부 규칙은 신중히**: paths가 정말 특정 파일 유형에만 해당할 때만 사용 +- **하위 디렉토리로 정리**: 관련 규칙을 그룹화 (frontend/, backend/) diff --git a/.claude/skills/skill-create/SKILL.md b/.claude/skills/skill-create/SKILL.md new file mode 100644 index 000000000..4a5b58040 --- /dev/null +++ b/.claude/skills/skill-create/SKILL.md @@ -0,0 +1,129 @@ +--- +name: skill-create +description: Claude Code용 새로운 스킬을 생성합니다. 사용자가 "스킬 만들어줘", "새 스킬 생성", "skill 만들어줘", "SKILL.md 작성해줘"라고 요청하거나, 특정 워크플로우를 스킬로 패키징하고 싶을 때 사용합니다. 스킬 수정이나 검증에는 사용하지 마세요. +--- + +# Skill Create + +Claude Code용 새로운 스킬을 생성하는 대화형 워크플로우입니다. + +## 워크플로우 + +### 단계 1: 요구사항 수집 + +사용자에게 다음을 확인합니다: + +1. **스킬의 목적**: 어떤 작업을 자동화하려는가? +2. **트리거 시나리오**: 사용자가 어떤 상황에서 이 스킬을 호출하는가? +3. **스킬 타입 결정**: + - 독립형 (외부 도구 불필요) + - MCP 연동 (외부 서비스 필요) + - 서브에이전트 실행 (context: fork 필요) +4. **호출 방식**: Claude 자동 호출 허용 여부 (`disable-model-invocation`) + +### 단계 2: 템플릿 선택 + +스킬 타입에 따라 적절한 템플릿을 선택합니다. + +- 독립형 → `assets/templates/basic.md` 참고 +- MCP 연동 → `assets/templates/mcp-enhanced.md` 참고 +- 서브에이전트 → `assets/templates/subagent.md` 참고 + +### 단계 3: 프론트매터 생성 + +`references/frontmatter-spec.md`의 검증 규칙을 반드시 준수합니다. + +**필수 검증 항목:** +- name: 케밥케이스, 소문자만, 공백/언더스코어 불가 +- name에 "claude" 또는 "anthropic" 포함 금지 +- description: 1024자 미만, XML 태그(`<` `>`) 금지 +- description에 "무엇을 하는지" + "언제 사용하는지" 반드시 포함 + +### 단계 4: description 작성 + +description은 스킬의 트리거 정확도를 결정하는 가장 중요한 요소입니다. + +`references/description-guide.md`를 참고하여 작성합니다. + +**반드시 포함할 것:** +- 스킬이 하는 구체적인 작업 +- 사용자가 실제로 말할 트리거 문구 2-3개 +- 해당되면 파일 유형 언급 +- 이 스킬이 담당하지 않는 범위 (과다 트리거 방지) + +### 단계 5: 명령어 본문 작성 + +다음 원칙을 따릅니다: + +1. **구체적이고 실행 가능하게**: "데이터를 검증하세요"가 아니라 검증 항목을 나열 +2. **에러 처리 포함**: 일반적인 실패 시나리오와 대응법 +3. **예시 제공**: 사용자 요청 → 스킬 동작 → 결과의 흐름 +4. **점진적 공개**: 핵심 명령어만 SKILL.md에, 상세 문서는 references/로 분리 + +### 단계 6: 파일 구조 생성 + +``` +{skill-name}/ +├── SKILL.md # 메인 스킬 파일 +├── references/ # 상세 문서 (필요시) +├── scripts/ # 실행 스크립트 (필요시) +└── assets/ # 템플릿, 에셋 (필요시) +``` + +**주의:** +- 폴더 내에 README.md를 포함하지 말 것 +- SKILL.md는 정확한 대소문자 준수 (SKILL.MD, skill.md 불가) +- SKILL.md 본문은 5,000단어 미만으로 유지 + +### 단계 7: 결과 확인 + +생성 완료 후 사용자에게 다음을 안내합니다: + +1. 생성된 파일 목록과 각 파일의 역할 +2. 설치 방법 (개인: `~/.claude/skills/`, 프로젝트: `.claude/skills/`) +3. 테스트 방법: 트리거 문구로 호출 테스트 +4. `skill-validate` 스킬로 품질 검증 권장 + +## 예시 + +### 예시 1: 독립형 스킬 생성 + +사용자: "코드 리뷰 스킬 만들어줘. PR diff를 분석해서 개선점을 제안하는 거야." + +동작: +1. 목적 확인 → 코드 리뷰 자동화 +2. 타입 결정 → 독립형 (코드 분석만 수행) +3. 템플릿 선택 → basic.md +4. 프론트매터 생성: + ```yaml + --- + name: code-review + description: PR diff를 분석하고 코드 개선점을 제안합니다. 사용자가 "코드 리뷰해줘", "PR 리뷰", "코드 피드백"을 요청할 때 사용합니다. + --- + ``` +5. 명령어 본문 작성 후 전달 + +### 예시 2: MCP 연동 스킬 생성 + +사용자: "Linear 이슈에서 자동으로 브랜치 만들고 PR 템플릿까지 생성하는 스킬이 필요해." + +동작: +1. 목적 확인 → Linear 이슈 기반 개발 워크플로우 +2. 타입 결정 → MCP 연동 (Linear MCP + GitHub MCP) +3. 템플릿 선택 → mcp-enhanced.md +4. 멀티 MCP 조율 패턴 적용 +5. 에러 처리: MCP 연결 실패 시나리오 포함 + +## 트러블슈팅 + +### description이 너무 모호해서 트리거가 안 될 때 +`references/description-guide.md`의 좋은/나쁜 예시를 참고하여 구체적인 트리거 문구를 추가합니다. + +### SKILL.md가 너무 길어질 때 +상세 문서를 `references/`로 분리하고, SKILL.md에서 링크합니다. +핵심 워크플로우만 SKILL.md에 남기세요. + +### 어떤 템플릿을 선택해야 할지 모를 때 +- 외부 서비스 호출이 필요한가? → MCP 연동 +- 격리된 환경에서 실행해야 하는가? → 서브에이전트 +- 둘 다 아니면 → 독립형 diff --git a/.claude/skills/skill-create/assets/templates/basic.md b/.claude/skills/skill-create/assets/templates/basic.md new file mode 100644 index 000000000..19aff6168 --- /dev/null +++ b/.claude/skills/skill-create/assets/templates/basic.md @@ -0,0 +1,35 @@ +--- +name: {skill-name} +description: {무엇을 하는지}. {사용자 트리거 문구 2-3개}를 요청할 때 사용합니다. +--- + +# {스킬 표시명} + +## 명령어 + +### 단계 1: {첫 번째 주요 단계} +{구체적인 동작 설명} + +### 단계 2: {두 번째 주요 단계} +{구체적인 동작 설명} + +### 단계 3: {결과 전달} +{사용자에게 결과를 어떻게 보여줄지} + +## 예시 + +### 예시 1: {일반적인 시나리오} + +사용자: "{사용자가 실제로 말할 문구}" + +동작: +1. {첫 번째 동작} +2. {두 번째 동작} + +결과: {기대 결과} + +## 트러블슈팅 + +### {일반적인 실패 시나리오} +**원인:** {왜 발생하는지} +**해결:** {어떻게 수정하는지} diff --git a/.claude/skills/skill-create/assets/templates/mcp-enhanced.md b/.claude/skills/skill-create/assets/templates/mcp-enhanced.md new file mode 100644 index 000000000..45da8a647 --- /dev/null +++ b/.claude/skills/skill-create/assets/templates/mcp-enhanced.md @@ -0,0 +1,57 @@ +--- +name: {skill-name} +description: {무엇을 하는지}. {사용자 트리거 문구 2-3개}를 요청할 때 사용합니다. {담당하지 않는 범위}에는 사용하지 마세요. +--- + +# {스킬 표시명} + +## 사전 조건 + +- {필요한 MCP 서버}가 연결되어 있어야 합니다 +- 연결 확인: Settings > Extensions > {서비스명} + +## 워크플로우 + +### 단계 1: {데이터 조회} +MCP 도구 호출: `{tool_name}` +매개변수: {필요한 파라미터} + +### 단계 2: {처리/변환} +{조회된 데이터를 어떻게 가공하는지} + +### 단계 3: {결과 반영} +MCP 도구 호출: `{tool_name}` +{처리된 결과를 외부 서비스에 반영} + +### 단계 4: {사용자에게 결과 전달} +{완료된 작업의 요약과 링크 제공} + +## 에러 처리 + +### MCP 연결 실패 +"Connection refused"가 보이면: +1. MCP 서버 실행 확인: Settings > Extensions +2. API 키 유효성 확인 +3. 재연결: Settings > Extensions > {서비스명} > Reconnect + +### 인증 만료 +1. OAuth 토큰 갱신 필요 +2. Settings > Extensions에서 재인증 + +### API 호출 실패 +1. 요청 파라미터 검증 +2. 서비스 상태 확인 +3. 재시도 (최대 2회) + +## 예시 + +### 예시 1: {일반적인 시나리오} + +사용자: "{사용자가 실제로 말할 문구}" + +동작: +1. {MCP 도구 호출 1} +2. {데이터 처리} +3. {MCP 도구 호출 2} + +결과: {기대 결과와 확인 링크} diff --git a/.claude/skills/skill-create/assets/templates/subagent.md b/.claude/skills/skill-create/assets/templates/subagent.md new file mode 100644 index 000000000..3cac5279a --- /dev/null +++ b/.claude/skills/skill-create/assets/templates/subagent.md @@ -0,0 +1,34 @@ +--- +name: {skill-name} +description: {무엇을 하는지}. {사용자 트리거 문구 2-3개}를 요청할 때 사용합니다. +context: fork +agent: {Explore | Plan | general-purpose | 커스텀 에이전트명} +allowed-tools: {필요한 도구 목록} +--- + +# {스킬 표시명} + +## 작업 + +$ARGUMENTS에 대해 다음을 수행합니다: + +### 1. {탐색/분석 단계} +{Glob, Grep, Read 등을 활용한 정보 수집} + +### 2. {처리 단계} +{수집된 정보를 기반으로 분석/변환} + +### 3. {결과 정리} +{구체적인 파일 참조를 포함한 결과 요약} + +## 에이전트 유형 선택 기준 + +- **Explore**: 코드베이스 탐색, 읽기 전용 분석 +- **Plan**: 구현 계획 수립, 아키텍처 검토 +- **general-purpose**: 범용 작업 (기본값) + +## 주의사항 + +- context: fork는 대화 기록에 접근 불가 +- 반드시 명시적 작업 지침이 있어야 함 +- 결과는 요약되어 메인 대화로 반환됨 diff --git a/.claude/skills/skill-create/references/description-guide.md b/.claude/skills/skill-create/references/description-guide.md new file mode 100644 index 000000000..5d886e9b7 --- /dev/null +++ b/.claude/skills/skill-create/references/description-guide.md @@ -0,0 +1,85 @@ +# Description 작성 가이드 + +description은 Claude가 스킬을 로드할지 결정하는 유일한 판단 기준입니다. +트리거 정확도의 90%가 여기서 결정됩니다. + +## 구조 공식 + +``` +[무엇을 하는지] + [언제 사용하는지] + [사용하지 않는 경우 (선택)] +``` + +## 좋은 예시 + +### 구체적 트리거 문구 포함 +```yaml +description: PR diff를 분석하고 코드 품질 개선점을 제안합니다. 사용자가 "코드 리뷰해줘", "PR 리뷰", "코드 피드백 줘"를 요청할 때 사용합니다. +``` + +### MCP 연동 스킬 +```yaml +description: Linear 이슈 기반으로 Git 브랜치 생성과 PR 템플릿을 자동 생성합니다. 사용자가 "이슈 작업 시작", "브랜치 만들어줘", "Linear 이슈 처리"를 요청할 때 사용합니다. 단순 이슈 조회에는 사용하지 마세요. +``` + +### 범위 한정 포함 +```yaml +description: CSV 데이터의 통계 분석과 회귀분석을 수행합니다. 사용자가 "데이터 분석", "통계 돌려줘", "회귀분석"을 요청할 때 사용합니다. 단순 데이터 시각화에는 사용하지 마세요 (data-viz 스킬을 대신 사용). +``` + +## 나쁜 예시 + +### 너무 모호 +```yaml +# 무엇을 하는지 불명확, 트리거 조건 없음 +description: 프로젝트를 도와줍니다. +``` + +### 트리거 누락 +```yaml +# 무엇을 하는지는 있지만, 언제 사용하는지 없음 +description: 정교한 다중 페이지 문서 시스템을 생성합니다. +``` + +### 너무 기술적 +```yaml +# 사용자가 이렇게 말하지 않음 +description: 계층적 관계를 가진 Project 엔티티 모델을 구현합니다. +``` + +### 너무 광범위 +```yaml +# 과다 트리거 유발 +description: 문서를 처리합니다. +``` + +## 작성 원칙 + +### 1. 사용자의 언어로 쓰세요 +사용자가 실제로 입력할 문구를 포함합니다. +"PR diff를 분석"보다 "코드 리뷰해줘"가 트리거 매칭에 효과적입니다. + +### 2. 부정 트리거를 추가하세요 +비슷하지만 다른 스킬이 처리해야 할 경우를 명시합니다. +"단순 조회에는 사용하지 마세요" 같은 문구가 과다 트리거를 방지합니다. + +### 3. 파일 유형을 언급하세요 +파일 기반 스킬이면 확장자를 명시합니다. +"CSV 파일", ".py 파일", "마크다운 문서" 등. + +### 4. 1024자 한도를 의식하세요 +핵심 트리거 문구에 집중하고, 상세 설명은 SKILL.md 본문에 작성합니다. + +## 과소 트리거 vs 과다 트리거 + +| 문제 | 증상 | 해결 | +|---|---|---| +| 과소 트리거 | 스킬이 로드되어야 할 때 안 됨 | 트리거 문구 추가, 동의어 포함 | +| 과다 트리거 | 관련 없는 쿼리에 스킬 로드 | 부정 트리거 추가, 범위 한정 | + +## 디버깅 방법 + +Claude에게 직접 물어보세요: +``` +"[스킬 이름] 스킬을 언제 사용하겠어?" +``` +Claude가 description을 기반으로 답변하므로, 누락된 트리거를 파악할 수 있습니다. diff --git a/.claude/skills/skill-create/references/frontmatter-spec.md b/.claude/skills/skill-create/references/frontmatter-spec.md new file mode 100644 index 000000000..08aaeae8a --- /dev/null +++ b/.claude/skills/skill-create/references/frontmatter-spec.md @@ -0,0 +1,95 @@ +# 프론트매터 스펙 + +SKILL.md 상단 `---` 마커 사이에 작성하는 YAML 메타데이터입니다. + +## 필수 필드 + +### name +- **규칙**: 케밥케이스만 허용 (소문자 + 하이픈) +- **최대**: 64자 +- **금지**: 공백, 언더스코어, 대문자, "claude"/"anthropic" 접두사 +- **폴더명과 일치 필수** + +```yaml +# 올바름 +name: code-review +name: sprint-planner +name: mcp-linear-sync + +# 잘못됨 +name: Code Review # 대문자, 공백 +name: code_review # 언더스코어 +name: claude-helper # "claude" 접두사 +``` + +### description +- **최대**: 1024자 +- **금지**: XML 태그 (`<` `>`) +- **필수 포함**: 무엇을 하는지(WHAT) + 언제 사용하는지(WHEN) +- 사용자가 실제로 말할 트리거 문구를 포함 + +## 선택 필드 + +### disable-model-invocation +- `true` 설정 시 Claude가 자동으로 이 스킬을 호출하지 않음 +- `/skill-name`으로 사용자만 수동 호출 가능 +- **사용 사례**: deploy, commit 등 부작용이 있는 작업 + +### user-invocable +- `false` 설정 시 `/` 메뉴에서 숨김 +- Claude만 자동으로 호출 가능 +- **사용 사례**: 배경 지식, 컨텍스트 제공용 스킬 + +### allowed-tools +- 스킬 활성화 시 Claude가 권한 요청 없이 사용 가능한 도구 목록 +- 예: `Read, Grep, Glob` (읽기 전용), `Bash(python:*)` (Python 실행) + +### context +- `fork` 설정 시 격리된 서브에이전트에서 실행 +- 대화 기록에 접근 불가 +- **주의**: 명시적 작업 지침이 없으면 의미 없는 출력 반환 + +### agent +- `context: fork` 시 사용할 서브에이전트 유형 +- 기본 제공: `Explore`, `Plan`, `general-purpose` +- `.claude/agents/`의 커스텀 에이전트도 지정 가능 +- 생략 시 `general-purpose` 사용 + +### model +- 스킬 활성화 시 사용할 모델 지정 + +### argument-hint +- 자동완성 시 표시되는 인수 힌트 +- 예: `[issue-number]`, `[filename] [format]` + +### license +- 오픈소스 공개 시 사용 +- 예: `MIT`, `Apache-2.0` + +### metadata +- 커스텀 키-값 쌍 +```yaml +metadata: + author: your-name + version: 1.0.0 + mcp-server: linear +``` + +## 보안 제한 + +프론트매터는 Claude의 시스템 프롬프트에 노출됩니다. + +- XML 꺾쇠 괄호 (`<` `>`) 금지 → 명령어 주입 방지 +- "claude"/"anthropic" 이름 사용 금지 → 예약어 +- YAML 내 코드 실행 불가 → 안전한 YAML 파싱 적용 + +## 검증 체크리스트 + +- [ ] `---` 구분자로 시작하고 끝나는가? +- [ ] name이 케밥케이스인가? +- [ ] name이 폴더명과 일치하는가? +- [ ] name에 "claude"/"anthropic"이 없는가? +- [ ] description이 1024자 미만인가? +- [ ] description에 WHAT + WHEN이 있는가? +- [ ] XML 태그가 없는가? +- [ ] 따옴표가 올바르게 닫혔는가? diff --git a/.claude/skills/skill-modify/SKILL.md b/.claude/skills/skill-modify/SKILL.md new file mode 100644 index 000000000..21d5b1005 --- /dev/null +++ b/.claude/skills/skill-modify/SKILL.md @@ -0,0 +1,161 @@ +--- +name: skill-modify +description: 기존 Claude Code 스킬을 개선합니다. 사용자가 "스킬 수정해줘", "스킬 개선해줘", "description 고쳐줘", "트리거 안 먹혀", "이 스킬 리팩토링해줘"를 요청할 때 사용합니다. 새 스킬 생성에는 skill-create를, 검증만 필요하면 skill-validate를 사용하세요. +--- + +# Skill Modify + +기존 Claude Code 스킬의 구조, 프론트매터, 명령어를 개선합니다. +수정은 항상 **진단 → 수정 → 검증** 사이클로 진행합니다. + +## 워크플로우 + +### 단계 1: 대상 스킬과 문제 파악 + +사용자에게 다음을 확인합니다: + +1. **수정할 스킬 경로** 확인 + - 직접 경로 지정 또는 현재 대화에서 작업 중인 스킬 +2. **문제 유형 파악** — 사용자가 말하는 증상으로 판단: + - "트리거가 안 돼" → description 과소 트리거 + - "엉뚱한 데서 뜨네" → description 과다 트리거 + - "결과가 불안정해" → 명령어 모호성 + - "에러 났을 때 멈춰" → 에러 처리 부재 + - "너무 느려 / 품질이 떨어져" → SKILL.md 비대 / 구조 문제 + - "자동으로 실행되면 안 되는데" → 프론트매터 필드 누락 + +### 단계 2: 현재 상태 진단 + +대상 스킬의 SKILL.md를 읽고 현재 상태를 파악합니다. + +**확인 항목:** +- 프론트매터: name, description, 선택 필드 현황 +- 명령어 본문: 단계 구성, 구체성, 에러 처리 +- 구조: 파일 구성, references 분리 상태, 단어 수 + +skill-validate의 검증 결과가 있으면 해당 결과를 기반으로 진단합니다. +없으면 SKILL.md를 직접 읽고 문제를 파악합니다. + +### 단계 3: 수정 패턴 선택 및 적용 + +`references/modification-patterns.md`에서 문제 유형에 맞는 패턴을 참고합니다. + +**수정 유형별 패턴:** + +| 문제 | 패턴 | 핵심 수정 | +|---|---|---| +| 과소 트리거 | 패턴 1 | 트리거 문구 3개 추가, 동의어 포함 | +| 과다 트리거 | 패턴 1 | 부정 트리거 추가, 범위 한정 | +| 모호한 명령어 | 패턴 2 | 구체적 조건/항목 나열, 의존성 명시 | +| 에러 처리 부재 | 패턴 3 | 스킬 유형별 기본 에러 시나리오 추가 | +| SKILL.md 비대 | 패턴 4 | references 분리, 핵심만 남기기 | +| 프론트매터 미흡 | 패턴 5 | disable-model-invocation, allowed-tools 등 추가 | + +**수정 원칙:** +- 한 번에 하나의 문제만 수정 (여러 문제가 있으면 임팩트 큰 순서대로) +- 기존 기능을 깨뜨리지 않는 범위에서 수정 +- 수정 전 원본을 보존 (사용자에게 before/after 비교 제공) + +### 단계 4: 수정 내용 제시 + +수정 결과를 사용자에게 다음 형식으로 보여줍니다: + +``` +══════════════════════════════════ + 수정 리포트: {skill-name} +══════════════════════════════════ + +[문제 진단] + {파악된 문제와 근거} + +[수정 사항] + + 1. {수정 항목} + 수정 전: {원본 내용} + 수정 후: {변경 내용} + 이유: {왜 이렇게 바꿨는지} + + 2. {추가 수정 항목이 있으면 계속} + +[미적용 사항] + {이번에 수정하지 않은 항목과 이유} +══════════════════════════════════ +``` + +### 단계 5: 사용자 확인 후 적용 + +수정 리포트를 보여준 후: +1. 사용자가 동의하면 실제 파일에 수정 적용 +2. 수정 거부 시 원본 유지 +3. 부분 수정 요청 시 선택한 항목만 적용 + +### 단계 6: 수정 후 검증 + +수정 적용 후 skill-validate로 재검증을 권장합니다. + +자동 검증이 가능하면 validate.sh를 직접 실행: +```bash +bash {skill-validate-path}/scripts/validate.sh {수정된 스킬 경로} +``` + +## 예시 + +### 예시 1: 과소 트리거 수정 + +사용자: "code-review 스킬이 '리뷰해줘'라고 하면 안 떠" + +동작: +1. 대상 확인 → code-review 스킬의 SKILL.md 읽기 +2. 진단 → description: "PR의 코드 품질을 분석합니다" + → "언제 사용하는지" 누락, 트리거 문구 없음 +3. 패턴 1 적용 → 트리거 문구 추가 +4. 수정 리포트: + ``` + 수정 전: PR의 코드 품질을 분석합니다. + 수정 후: PR의 코드 품질을 분석하고 개선점을 제안합니다. 사용자가 + "코드 리뷰해줘", "PR 리뷰", "리뷰해줘", "코드 피드백"을 + 요청할 때 사용합니다. + 이유: 사용자가 실제로 말할 트리거 문구 4개를 추가하여 매칭률 향상 + ``` +5. 사용자 확인 후 적용 +6. skill-validate 재검증 권장 + +### 예시 2: 복합 문제 수정 + +사용자: "이 스킬 validate 돌렸더니 재작성 필요래" + +동작: +1. skill-validate 결과 확인: + - 트리거 품질 C, 에러 처리 F, 나머지 B +2. 임팩트 순서 결정: 에러 처리(F→B) > 트리거(C→B) +3. 첫 번째 수정: 에러 처리 섹션 추가 (패턴 3) +4. 두 번째 수정: description 트리거 문구 보강 (패턴 1) +5. 수정 리포트에 두 건 모두 포함 +6. 적용 후 재검증 → "개선 후 배포" 또는 "배포 가능" 목표 + +### 예시 3: 구조 리팩토링 + +사용자: "SKILL.md가 너무 길어졌어, 정리해줘" + +동작: +1. SKILL.md 단어 수 확인 → 7200단어 +2. 패턴 4 적용: + - API 스펙 (2000단어) → references/api-spec.md 분리 + - 예시 모음 (1500단어) → references/examples.md 분리 + - SKILL.md에 참조 링크 추가 +3. 수정 후 SKILL.md: 3700단어 (5000 이하 달성) +4. 수정 리포트에 분리된 파일 목록과 링크 포함 + +## 트러블슈팅 + +### 수정 후 기존 트리거가 깨진 경우 +description을 수정할 때 기존 트리거 문구를 제거하지 않았는지 확인합니다. +부정 트리거 추가가 기존 유효 트리거를 차단하지 않는지 확인합니다. + +### 어떤 패턴을 적용해야 할지 모를 때 +먼저 skill-validate를 실행하여 등급을 확인합니다. +F 등급 항목부터 해당 패턴을 적용합니다. + +### 수정 범위가 너무 클 때 +스킬의 핵심 목적 자체가 불명확하면 skill-modify보다 skill-create로 +새로 만드는 것을 권장합니다. 기준: 5개 검증 항목 중 3개 이상이 F일 때. diff --git a/.claude/skills/skill-modify/references/modification-patterns.md b/.claude/skills/skill-modify/references/modification-patterns.md new file mode 100644 index 000000000..4387a13b7 --- /dev/null +++ b/.claude/skills/skill-modify/references/modification-patterns.md @@ -0,0 +1,230 @@ +# 스킬 수정 패턴 + +skill-validate의 등급 결과를 기반으로 적용하는 구체적인 수정 패턴입니다. + +## 패턴 1: description 트리거 개선 + +### 과소 트리거 (스킬이 호출되어야 할 때 안 됨) + +**진단**: description에 사용자가 실제로 말할 문구가 부족합니다. + +**수정 전:** +```yaml +description: 프로젝트 워크스페이스를 설정합니다. +``` + +**수정 방법:** +1. 사용자가 이 스킬을 호출할 때 말할 자연어 문구 3개를 추가 +2. 동의어와 변형 표현을 포함 +3. 관련 파일 유형이 있으면 명시 + +**수정 후:** +```yaml +description: 프로젝트 워크스페이스를 설정합니다. 사용자가 "프로젝트 만들어줘", + "워크스페이스 초기화", "새 프로젝트 셋업"을 요청할 때 사용합니다. +``` + +### 과다 트리거 (관련 없는 쿼리에 스킬이 로드됨) + +**진단**: description 범위가 너무 넓거나 부정 트리거가 없습니다. + +**수정 전:** +```yaml +description: 문서를 처리합니다. 사용자가 문서 관련 작업을 요청할 때 사용합니다. +``` + +**수정 방법:** +1. 구체적인 문서 유형과 작업을 명시 +2. 담당하지 않는 범위를 부정 트리거로 추가 +3. 비슷한 스킬이 있으면 구분점을 명시 + +**수정 후:** +```yaml +description: PDF 법률 문서의 계약 조항을 분석하고 검토 의견을 생성합니다. + 사용자가 "계약서 리뷰해줘", "PDF 계약 분석", "조항 검토"를 요청할 때 사용합니다. + 일반 PDF 변환이나 서식 편집에는 사용하지 마세요. +``` + +## 패턴 2: 명령어 구체화 + +### 모호한 표현 제거 + +**대상**: "적절히", "제대로", "올바르게", "필요하면" 같은 모호한 지시 + +**수정 전:** +```markdown +### 단계 2: 데이터 검증 +데이터를 적절히 검증하고 문제가 있으면 처리합니다. +``` + +**수정 방법:** +1. "적절히"를 구체적인 검증 항목 목록으로 대체 +2. "문제가 있으면"을 구체적인 조건으로 대체 +3. "처리합니다"를 구체적인 액션으로 대체 + +**수정 후:** +```markdown +### 단계 2: 데이터 검증 +다음 항목을 검증합니다: +- 필수 필드(name, email, project_id)가 비어있지 않은지 확인 +- 날짜 형식이 YYYY-MM-DD인지 확인 +- 숫자 필드에 음수가 없는지 확인 + +검증 실패 시: +- 실패한 항목과 기대 형식을 사용자에게 안내 +- 수정 후 재실행을 권장 +``` + +### 단계 간 의존성 명시 + +**대상**: 이전 단계의 결과를 다음 단계에서 사용하는데 명시하지 않은 경우 + +**수정 전:** +```markdown +### 단계 1: 프로젝트 생성 +MCP 도구 호출: create_project + +### 단계 2: 태스크 생성 +MCP 도구 호출: create_task +``` + +**수정 후:** +```markdown +### 단계 1: 프로젝트 생성 +MCP 도구 호출: `create_project` +매개변수: name, description +반환값: `project_id` ← 단계 2에서 사용 + +### 단계 2: 태스크 생성 +MCP 도구 호출: `create_task` +매개변수: title, assignee, `project_id` (단계 1에서 획득) +``` + +## 패턴 3: 에러 처리 보강 + +### 에러 섹션이 전혀 없는 경우 + +**수정 방법:** +1. 스킬 유형에 따라 기본 에러 시나리오를 추가 +2. 각 에러에 원인과 해결책을 함께 작성 + +**독립형 스킬 기본 에러:** +```markdown +## 트러블슈팅 + +### 입력 데이터 형식 오류 +**원인:** 예상과 다른 형식의 데이터가 입력됨 +**해결:** 입력 형식을 안내하고 재입력 요청 + +### 출력 파일 생성 실패 +**원인:** 디스크 권한 또는 경로 문제 +**해결:** 출력 경로 확인 후 재시도 +``` + +**MCP 연동 스킬 필수 에러:** +```markdown +## 트러블슈팅 + +### MCP 연결 실패 +**증상:** "Connection refused" 또는 응답 없음 +**확인:** Settings > Extensions > {서비스명} 상태 확인 +**해결:** Reconnect 시도, 안 되면 MCP 서버 재시작 + +### 인증 만료 +**증상:** 401 Unauthorized 또는 "Token expired" +**확인:** API 키 유효기간 확인 +**해결:** Settings > Extensions에서 재인증 + +### 권한 부족 +**증상:** 403 Forbidden +**확인:** API 키에 필요한 스코프가 부여되었는지 +**해결:** 서비스 관리 콘솔에서 권한 확인 및 부여 + +### 속도 제한 +**증상:** 429 Too Many Requests +**해결:** 30초 대기 후 재시도 (최대 2회) +``` + +## 패턴 4: 구조 리팩토링 + +### SKILL.md가 5000단어 초과 + +**수정 방법:** +1. SKILL.md에서 핵심 워크플로우만 남기기 +2. 상세 문서를 references/로 분리 +3. SKILL.md에서 분리한 파일을 링크 + +**분리 기준:** +- API 스펙, 필드 목록 → `references/api-spec.md` +- 상세 예시 모음 → `references/examples.md` +- 도메인 지식/규칙 → `references/domain-rules.md` +- 핵심 워크플로우 단계 → SKILL.md에 유지 + +**분리 후 SKILL.md에 추가:** +```markdown +## 참고 자료 +- API 상세 스펙: [references/api-spec.md](references/api-spec.md) 참고 +- 추가 예시: [references/examples.md](references/examples.md) 참고 +``` + +### 불필요한 파일 정리 + +**확인 항목:** +- references/ 내 파일이 SKILL.md에서 참조되고 있는가? +- scripts/ 내 스크립트가 실제로 호출되는가? +- assets/ 내 템플릿이 워크플로우에서 사용되는가? + +참조되지 않는 파일은 제거하거나 SKILL.md에서 링크를 추가합니다. + +## 패턴 5: 프론트매터 필드 조정 + +### 자동 호출이 위험한 스킬 + +**진단**: 부작용이 있는 작업(배포, 삭제, 메시지 발송 등)인데 `disable-model-invocation`이 없음 + +**수정:** +```yaml +--- +name: deploy-prod +description: 프로덕션 배포를 실행합니다. +disable-model-invocation: true # 추가: 사용자만 수동 호출 +--- +``` + +### 도구 접근 범위 제한 + +**진단**: 읽기만 필요한 스킬인데 모든 도구에 접근 가능 + +**수정:** +```yaml +--- +name: code-analyzer +description: 코드 분석 및 리포트 생성 +allowed-tools: Read, Grep, Glob # 추가: 읽기 전용 도구만 +--- +``` + +### 서브에이전트 전환 + +**진단**: 메인 대화 컨텍스트를 오염시키는 무거운 작업 + +**수정:** +```yaml +--- +name: deep-research +description: 코드베이스 심층 분석 +context: fork # 추가: 격리된 서브에이전트에서 실행 +agent: Explore # 추가: 읽기 전용 탐색 에이전트 +--- +``` + +## 수정 시 체크리스트 + +수정 완료 후 반드시 확인: + +- [ ] 수정이 원래 스킬의 핵심 기능을 변경하지 않았는가? +- [ ] 기존 트리거 문구가 여전히 동작하는가? +- [ ] 새로 추가한 내용이 기존 내용과 모순되지 않는가? +- [ ] SKILL.md 단어 수가 5000 이하인가? +- [ ] references에서 분리한 파일이 SKILL.md에서 링크되어 있는가? +- [ ] 프론트매터 변경 시 YAML 문법이 올바른가? diff --git a/.claude/skills/skill-validate/SKILL.md b/.claude/skills/skill-validate/SKILL.md new file mode 100644 index 000000000..c61d811ab --- /dev/null +++ b/.claude/skills/skill-validate/SKILL.md @@ -0,0 +1,145 @@ +--- +name: skill-validate +description: Claude Code 스킬의 구조와 품질을 검증합니다. 사용자가 "스킬 검증해줘", "스킬 품질 체크", "SKILL.md 리뷰해줘", "이 스킬 괜찮은지 확인해줘"를 요청할 때 사용합니다. 스킬 생성이나 수정에는 사용하지 마세요. +allowed-tools: Bash(bash:*) Read Grep Glob +--- + +# Skill Validate + +Claude Code 스킬의 구조 검증과 품질 평가를 수행합니다. +검증은 **자동 검증**(스크립트)과 **수동 검증**(Claude 판단) 두 단계로 나뉩니다. + +## 워크플로우 + +### 단계 1: 대상 스킬 확인 + +사용자에게 검증할 스킬의 경로를 확인합니다. + +- 직접 경로 지정: `skill-validate ./my-skill` +- 현재 대화에서 생성한 스킬이 있으면 해당 경로 사용 + +스킬 디렉토리에 SKILL.md가 있는지 먼저 확인합니다. + +### 단계 2: 자동 검증 실행 + +`scripts/validate.sh`를 실행하여 기계적으로 검증 가능한 항목을 체크합니다. + +```bash +bash scripts/validate.sh +``` + +**자동 검증 항목:** +- 파일 구조 (SKILL.md 존재, 폴더명 케밥케이스, README.md 금지) +- 프론트매터 구조 (`---` 구분자, YAML 파싱) +- name 필드 (케밥케이스, 64자 제한, 예약어, 폴더명 일치) +- description 필드 (1024자 제한, XML 태그 금지, 트리거 문구) +- 콘텐츠 품질 (본문 존재, 5000단어 제한, 예시/에러 섹션) + +실패(✗)가 있으면 사용자에게 즉시 알리고, 수동 검증 전에 수정을 권장합니다. + +### 단계 3: 수동 검증 (Claude 판단) + +`references/quality-criteria.md`의 기준에 따라 5개 항목을 평가합니다. + +#### 3-1. 트리거 품질 +SKILL.md의 description을 읽고 평가합니다: +- 사용자가 자연스럽게 말할 트리거 문구가 포함되어 있는가? +- 과다 트리거를 방지하는 범위 한정이 있는가? +- 트리거되어야 할 쿼리 3개, 트리거되면 안 될 쿼리 3개를 생성하여 시뮬레이션 + +#### 3-2. 명령어 명확성 +SKILL.md 본문의 명령어를 읽고 평가합니다: +- 각 단계가 구체적이고 실행 가능한가? +- "적절히", "제대로" 같은 모호한 표현이 없는가? +- 단계 간 의존성이 명확한가? + +#### 3-3. 에러 처리 +- 일반적인 실패 시나리오가 문서화되어 있는가? +- MCP 스킬이면 연결 실패/인증 만료/API 에러를 다루는가? + +#### 3-4. 구조 효율성 +- SKILL.md가 핵심 명령어에 집중하는가? +- 상세 문서가 references/로 분리되어 있는가? +- 참조되지 않는 파일이 없는가? + +#### 3-5. 확장성 +- 다른 스킬과 범위가 겹치지 않는가? +- 환경 독립적인가? + +### 단계 4: 리포트 출력 + +`references/quality-criteria.md`에 정의된 리포트 형식으로 결과를 출력합니다. + +``` +═══════════════════════════════════════ + 스킬 품질 리포트: {skill-name} +═══════════════════════════════════════ + +[자동 검증] validate.sh 결과 + 통과: N개 / 실패: N개 / 경고: N개 + +[수동 검증] 품질 평가 + 1. 트리거 품질: [A/B/C/F] + 2. 명령어 명확성: [A/B/C/F] + 3. 에러 처리: [A/B/C/F] + 4. 구조 효율성: [A/B/C/F] + 5. 확장성: [A/B/C/F] + +[종합] {배포 가능 / 개선 후 배포 / 재작성 필요} + +[개선 제안] + 1. {가장 중요한 개선 사항} + 2. {두 번째 개선 사항} + 3. {세 번째 개선 사항} +═══════════════════════════════════════ +``` + +**종합 판정 기준:** +- **배포 가능**: 모든 항목 B 이상, 자동 검증 실패 0개 +- **개선 후 배포**: C가 1-2개 있거나 자동 검증 경고만 존재 +- **재작성 필요**: F가 1개 이상 또는 C가 3개 이상 + +### 단계 5: 개선 안내 + +"재작성 필요" 또는 "개선 후 배포" 판정 시: +- 가장 임팩트가 큰 개선 사항 3개를 구체적으로 제안 +- `skill-modify` 스킬 사용을 안내하여 개선 진행 권장 + +## 예시 + +### 예시 1: 잘 만들어진 스킬 검증 + +사용자: "이 code-review 스킬 검증해줘" + +동작: +1. validate.sh 실행 → 통과 12개, 실패 0개, 경고 0개 +2. 수동 검증 → 트리거 A, 명확성 A, 에러 B, 구조 A, 확장성 A +3. 종합: 배포 가능 +4. 개선 제안: 에러 처리에 MCP 연결 실패 시나리오 추가 권장 + +### 예시 2: 개선이 필요한 스킬 검증 + +사용자: "방금 만든 스킬 품질 체크해줘" + +동작: +1. validate.sh 실행 → 통과 8개, 실패 1개 (description에 XML 태그), 경고 2개 +2. 자동 검증 실패 사항 즉시 알림 +3. 수동 검증 → 트리거 C (트리거 문구 부족), 명확성 B, 에러 F (에러 처리 없음), 구조 B, 확장성 B +4. 종합: 재작성 필요 +5. 개선 제안: + - description에서 XML 태그 제거 (자동 검증 실패) + - 에러 처리 섹션 추가 (F → B 이상 목표) + - description에 구체적 트리거 문구 2-3개 추가 + +## 트러블슈팅 + +### validate.sh 실행 권한 에러 +```bash +chmod +x scripts/validate.sh +``` +또는 `bash scripts/validate.sh`로 직접 실행합니다. + +### 스킬 경로를 모를 때 +- 개인 스킬: `~/.claude/skills/` 하위 +- 프로젝트 스킬: `.claude/skills/` 하위 +- `find . -name "SKILL.md"` 로 검색 diff --git a/.claude/skills/skill-validate/references/quality-criteria.md b/.claude/skills/skill-validate/references/quality-criteria.md new file mode 100644 index 000000000..ea6264850 --- /dev/null +++ b/.claude/skills/skill-validate/references/quality-criteria.md @@ -0,0 +1,132 @@ +# 스킬 품질 기준 + +validate.sh가 기계적 규칙을 검증한 이후, Claude가 판단해야 하는 품질 기준입니다. + +## 1. 트리거 품질 (Trigger Quality) + +### 평가 항목 +- description에 사용자가 자연스럽게 말할 문구가 포함되어 있는가? +- 동의어/변형 표현이 충분히 커버되는가? +- 과다 트리거를 방지하는 범위 한정이 있는가? +- 비슷한 스킬과 구분이 명확한가? + +### 등급 기준 +- **A**: 명확한 트리거 문구 3개 이상 + 부정 트리거 포함 +- **B**: 트리거 문구 2개 이상, 부정 트리거 없음 +- **C**: 트리거 문구 1개 이하 또는 모호한 description +- **F**: description이 "무엇을 하는지"만 있고 "언제 사용하는지"가 없음 + +### 트리거 테스트 방법 +다음 유형의 쿼리를 시뮬레이션하여 평가합니다: + +``` +트리거되어야 하는 쿼리 (3-5개): +- [스킬의 주요 사용 사례에 해당하는 자연어 쿼리] + +트리거되면 안 되는 쿼리 (3-5개): +- [비슷하지만 다른 스킬이 담당해야 할 쿼리] +- [관련 없는 쿼리] +``` + +## 2. 명령어 명확성 (Instruction Clarity) + +### 평가 항목 +- 각 단계가 구체적이고 실행 가능한가? +- 모호한 표현("적절히 처리", "제대로 검증") 없이 구체적인 조건이 명시되어 있는가? +- 단계 간 의존성이 명확한가? +- Claude가 판단해야 할 부분과 기계적으로 실행할 부분이 구분되어 있는가? + +### 등급 기준 +- **A**: 모든 단계가 구체적, 검증 조건 명시, 예시 포함 +- **B**: 대부분 구체적이나 일부 모호한 단계 존재 +- **C**: 절반 이상의 단계가 모호하거나 실행 불가능 +- **F**: "적절히 처리하세요" 수준의 명령어 + +### 모호한 표현 체크리스트 +다음 표현이 있으면 구체화가 필요합니다: +- "적절히", "제대로", "올바르게" → 구체적인 기준으로 대체 +- "필요하면", "경우에 따라" → 조건을 명시 +- "검증하세요" → 검증 항목을 나열 +- "처리하세요" → 처리 단계를 구체화 + +## 3. 에러 처리 (Error Handling) + +### 평가 항목 +- 일반적인 실패 시나리오가 문서화되어 있는가? +- 각 에러에 대한 원인과 해결책이 있는가? +- MCP 연동 스킬의 경우, 연결 실패/인증 만료/API 에러를 다루는가? +- 사용자에게 도움이 되는 에러 메시지를 제공하는가? + +### 등급 기준 +- **A**: 3개 이상의 에러 시나리오 + 원인 + 해결책 +- **B**: 1-2개 에러 시나리오 있음 +- **C**: 에러 처리 언급은 있으나 구체적 시나리오 없음 +- **F**: 에러 처리 전혀 없음 + +### MCP 스킬 필수 에러 시나리오 +- 연결 실패 (Connection refused) +- 인증 만료 (Token expired) +- 권한 부족 (Permission denied) +- 속도 제한 (Rate limited) +- 서비스 다운 (Service unavailable) + +## 4. 구조 효율성 (Structure Efficiency) + +### 평가 항목 +- SKILL.md가 핵심 명령어에 집중하고 있는가? +- 상세 문서가 references/로 적절히 분리되어 있는가? +- references 파일이 SKILL.md에서 참조되고 있는가? +- 불필요한 파일이 없는가? + +### 등급 기준 +- **A**: SKILL.md ≤ 3000단어, 참조 분리 적절, 모든 파일 연결됨 +- **B**: SKILL.md ≤ 5000단어, 약간의 개선 여지 +- **C**: SKILL.md > 5000단어 또는 참조되지 않는 파일 존재 +- **F**: 모든 내용이 SKILL.md에 뭉쳐있고 5000단어 초과 + +## 5. 확장성 (Extensibility) + +### 평가 항목 +- 새로운 기능 추가 시 기존 구조를 수정하지 않고 확장 가능한가? +- 스킬이 다른 스킬과 충돌 없이 공존 가능한가? +- 환경(Claude.ai, Claude Code, API)에 독립적인가? + +### 등급 기준 +- **A**: 명확한 범위 한정, 다른 스킬과 겹침 없음, 환경 독립적 +- **B**: 대부분 독립적이나 일부 환경 의존성 +- **C**: 다른 스킬과 범위가 겹치거나 특정 환경에 종속 +- **F**: 다른 스킬을 깨뜨릴 가능성 있음 + +## 종합 등급 + +| 종합 등급 | 조건 | +|---|---| +| **배포 가능 (Ready)** | 모든 항목 B 이상, 실패 0개 | +| **개선 후 배포 (Needs Work)** | C가 1-2개 있거나 경고 존재 | +| **재작성 필요 (Rework)** | F가 1개 이상 또는 C가 3개 이상 | + +## 리포트 출력 형식 + +``` +═══════════════════════════════════════ + 스킬 품질 리포트: {skill-name} +═══════════════════════════════════════ + +[자동 검증] validate.sh 결과 + 통과: N개 / 실패: N개 / 경고: N개 + +[수동 검증] 품질 평가 + 1. 트리거 품질: [A/B/C/F] - {한 줄 요약} + 2. 명령어 명확성: [A/B/C/F] - {한 줄 요약} + 3. 에러 처리: [A/B/C/F] - {한 줄 요약} + 4. 구조 효율성: [A/B/C/F] - {한 줄 요약} + 5. 확장성: [A/B/C/F] - {한 줄 요약} + +[종합] {배포 가능 / 개선 후 배포 / 재작성 필요} + +[개선 제안] + 1. {가장 중요한 개선 사항} + 2. {두 번째 개선 사항} + 3. {세 번째 개선 사항} +═══════════════════════════════════════ +``` diff --git a/.claude/skills/skill-validate/scripts/validate.sh b/.claude/skills/skill-validate/scripts/validate.sh new file mode 100755 index 000000000..d8a2c9716 --- /dev/null +++ b/.claude/skills/skill-validate/scripts/validate.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# skill-validate: 스킬 구조 및 프론트매터 자동 검증 +# 사용법: bash validate.sh + +set -euo pipefail + +# ───────────────────────────────────────────── +# 색상 정의 +# ───────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# ───────────────────────────────────────────── +# 카운터 +# ───────────────────────────────────────────── +PASS=0 +FAIL=0 +WARN=0 + +pass() { + echo -e " ${GREEN}✓${NC} $1" + PASS=$((PASS + 1)) +} + +fail() { + echo -e " ${RED}✗${NC} $1" + FAIL=$((FAIL + 1)) +} + +warn() { + echo -e " ${YELLOW}⚠${NC} $1" + WARN=$((WARN + 1)) +} + +# ───────────────────────────────────────────── +# 인수 검증 +# ───────────────────────────────────────────── +if [ $# -eq 0 ]; then + echo -e "${RED}사용법: bash validate.sh ${NC}" + echo "예시: bash validate.sh ./my-skill" + exit 1 +fi + +SKILL_DIR="$1" + +if [ ! -d "$SKILL_DIR" ]; then + echo -e "${RED}에러: '$SKILL_DIR'는 유효한 디렉토리가 아닙니다.${NC}" + exit 1 +fi + +SKILL_DIR_NAME=$(basename "$SKILL_DIR") + +echo "" +echo -e "${BOLD}${CYAN}═══════════════════════════════════════════${NC}" +echo -e "${BOLD}${CYAN} 스킬 검증 리포트: ${SKILL_DIR_NAME}${NC}" +echo -e "${BOLD}${CYAN}═══════════════════════════════════════════${NC}" +echo "" + +# ═════════════════════════════════════════════ +# 1. 파일 구조 검증 +# ═════════════════════════════════════════════ +echo -e "${BOLD}[1/5] 파일 구조 검증${NC}" + +# SKILL.md 존재 확인 (대소문자 정확히) +if [ -f "$SKILL_DIR/SKILL.md" ]; then + pass "SKILL.md 파일 존재" +else + # 대소문자 변형 체크 + FOUND=$(find "$SKILL_DIR" -maxdepth 1 -iname "skill.md" -type f 2>/dev/null) + if [ -n "$FOUND" ]; then + fail "SKILL.md 파일명 대소문자 오류 → 발견된 파일: $(basename "$FOUND")" + else + fail "SKILL.md 파일 없음 (필수)" + fi + echo -e "\n${RED}SKILL.md가 없어 검증을 계속할 수 없습니다.${NC}" + exit 1 +fi + +# README.md 금지 +if [ -f "$SKILL_DIR/README.md" ]; then + fail "README.md가 스킬 폴더 안에 있음 → 스킬 폴더 내 README.md 금지" +else + pass "스킬 폴더 내 README.md 없음" +fi + +# 폴더명 케밥케이스 검증 +if echo "$SKILL_DIR_NAME" | grep -qE '^[a-z0-9]+(-[a-z0-9]+)*$'; then + pass "폴더명 케밥케이스: $SKILL_DIR_NAME" +else + fail "폴더명이 케밥케이스가 아님: $SKILL_DIR_NAME (소문자+하이픈만 허용)" +fi + +echo "" + +# ═════════════════════════════════════════════ +# 2. 프론트매터 구조 검증 +# ═════════════════════════════════════════════ +echo -e "${BOLD}[2/5] 프론트매터 구조 검증${NC}" + +SKILL_FILE="$SKILL_DIR/SKILL.md" +CONTENT=$(cat "$SKILL_FILE") + +# --- 구분자 확인 +FIRST_LINE=$(head -1 "$SKILL_FILE") +if [ "$FIRST_LINE" = "---" ]; then + pass "프론트매터 시작 구분자 (---) 존재" +else + fail "프론트매터 시작 구분자 (---) 없음 → 첫 번째 줄이 '---'이어야 함" +fi + +# 닫는 --- 확인 (두 번째 --- 찾기) +CLOSING_LINE=$(awk 'NR>1 && /^---$/{print NR; exit}' "$SKILL_FILE") +if [ -n "$CLOSING_LINE" ]; then + pass "프론트매터 종료 구분자 (---) 존재 (${CLOSING_LINE}번째 줄)" +else + fail "프론트매터 종료 구분자 (---) 없음" +fi + +# 프론트매터 추출 +if [ -n "$CLOSING_LINE" ]; then + FRONTMATTER=$(sed -n "2,$((CLOSING_LINE - 1))p" "$SKILL_FILE") +else + FRONTMATTER="" +fi + +echo "" + +# ═════════════════════════════════════════════ +# 3. name 필드 검증 +# ═════════════════════════════════════════════ +echo -e "${BOLD}[3/5] name 필드 검증${NC}" + +NAME=$(echo "$FRONTMATTER" | sed -n 's/^name:[[:space:]]*//p' | xargs 2>/dev/null || echo "") + +if [ -z "$NAME" ]; then + fail "name 필드 없음 (필수)" +else + pass "name 필드 존재: $NAME" + + # 케밥케이스 검증 + if echo "$NAME" | grep -qE '^[a-z0-9]+(-[a-z0-9]+)*$'; then + pass "name 케밥케이스 준수" + else + fail "name이 케밥케이스가 아님: $NAME" + # 구체적 원인 진단 + if echo "$NAME" | grep -qE '[A-Z]'; then + echo -e " → 대문자 포함됨" + fi + if echo "$NAME" | grep -qE '[[:space:]]'; then + echo -e " → 공백 포함됨" + fi + if echo "$NAME" | grep -qE '_'; then + echo -e " → 언더스코어 포함됨" + fi + fi + + # 64자 제한 + NAME_LEN=${#NAME} + if [ "$NAME_LEN" -le 64 ]; then + pass "name 길이: ${NAME_LEN}자 (≤64)" + else + fail "name이 64자 초과: ${NAME_LEN}자" + fi + + # 예약어 검증 + if echo "$NAME" | grep -qiE '^(claude|anthropic)'; then + fail "name에 예약어 접두사 사용: $NAME (claude/anthropic 금지)" + else + pass "예약어 접두사 없음" + fi + + # 폴더명 일치 검증 + if [ "$NAME" = "$SKILL_DIR_NAME" ]; then + pass "name과 폴더명 일치" + else + warn "name($NAME)과 폴더명($SKILL_DIR_NAME) 불일치 → 일치 권장" + fi +fi + +echo "" + +# ═════════════════════════════════════════════ +# 4. description 필드 검증 +# ═════════════════════════════════════════════ +echo -e "${BOLD}[4/5] description 필드 검증${NC}" + +# 멀티라인 description 추출 (sed 기반, UTF-8 호환) +# 단일 라인: description: 값 +# 멀티 라인: description: 값\n 이어지는 줄 (들여쓰기로 판단) +DESC_FIRST=$(echo "$FRONTMATTER" | sed -n 's/^description:[ \t]*//p') +if [ -n "$DESC_FIRST" ]; then + # description 라인 번호 찾기 + DESC_LINE=$(echo "$FRONTMATTER" | grep -n '^description:' | head -1 | cut -d: -f1) + DESC="$DESC_FIRST" + # 다음 줄부터 들여쓰기된 연속 라인 병합 (멀티라인 YAML) + TOTAL_LINES=$(echo "$FRONTMATTER" | wc -l) + NEXT_LINE=$((DESC_LINE + 1)) + while [ "$NEXT_LINE" -le "$TOTAL_LINES" ]; do + LINE=$(echo "$FRONTMATTER" | sed -n "${NEXT_LINE}p") + # 들여쓰기로 시작하면 연속 라인 + if echo "$LINE" | grep -qE '^[[:space:]]'; then + LINE_TRIMMED=$(echo "$LINE" | sed 's/^[ \t]*//') + DESC="$DESC $LINE_TRIMMED" + else + break + fi + NEXT_LINE=$((NEXT_LINE + 1)) + done +fi +# 따옴표 제거 +DESC=$(echo "$DESC" | sed 's/^["'"'"']//;s/["'"'"']$//') + +if [ -z "$DESC" ]; then + fail "description 필드 없음 (필수)" +else + pass "description 필드 존재" + + # 1024자 제한 + DESC_LEN=${#DESC} + if [ "$DESC_LEN" -lt 1024 ]; then + pass "description 길이: ${DESC_LEN}자 (<1024)" + else + fail "description이 1024자 이상: ${DESC_LEN}자" + fi + + # XML 태그 검사 + if echo "$DESC" | grep -qE '[<>]'; then + fail "description에 XML 태그 문자 (<>) 포함 → 보안 제한 위반" + else + pass "XML 태그 문자 없음" + fi + + # "무엇을 하는지" 존재 여부 (휴리스틱) + # description이 20자 미만이면 너무 짧아서 내용이 부족할 가능성 + if [ "$DESC_LEN" -lt 20 ]; then + warn "description이 매우 짧음 (${DESC_LEN}자) → 무엇을 하는지/언제 사용하는지 포함 확인 필요" + else + pass "description 최소 길이 충족" + fi + + # 트리거 문구 포함 여부 (휴리스틱) + TRIGGER_KEYWORDS="사용합니다|사용하세요|Use when|use this|트리거|trigger|요청할 때|asks for|mentions" + if echo "$DESC" | grep -qiE "$TRIGGER_KEYWORDS"; then + pass "트리거 조건 문구 감지됨" + else + warn "트리거 조건 문구가 명시적이지 않음 → '...할 때 사용합니다' 패턴 권장" + fi +fi + +echo "" + +# ═════════════════════════════════════════════ +# 5. 콘텐츠 품질 검증 +# ═════════════════════════════════════════════ +echo -e "${BOLD}[5/5] 콘텐츠 품질 검증${NC}" + +# SKILL.md 본문 추출 (프론트매터 이후) +if [ -n "$CLOSING_LINE" ]; then + BODY=$(tail -n +"$((CLOSING_LINE + 1))" "$SKILL_FILE") +else + BODY="$CONTENT" +fi + +# 본문 존재 여부 +BODY_TRIMMED=$(echo "$BODY" | sed '/^$/d' | head -1) +if [ -n "$BODY_TRIMMED" ]; then + pass "SKILL.md 본문 콘텐츠 존재" +else + fail "SKILL.md 본문이 비어있음" +fi + +# 단어 수 체크 (5000단어 권장 상한) +WORD_COUNT=$(echo "$BODY" | wc -w | xargs) +if [ "$WORD_COUNT" -le 5000 ]; then + pass "SKILL.md 본문 크기: ${WORD_COUNT}단어 (≤5000 권장)" +else + warn "SKILL.md 본문이 ${WORD_COUNT}단어 → 5000단어 이하 권장, references/로 분리 고려" +fi + +# 예시 섹션 존재 여부 +if echo "$BODY" | grep -qiE '##.*예시|##.*example'; then + pass "예시(example) 섹션 존재" +else + warn "예시 섹션 없음 → 사용자 요청 → 동작 → 결과 예시 권장" +fi + +# 에러 처리/트러블슈팅 섹션 존재 여부 +if echo "$BODY" | grep -qiE '##.*에러|##.*트러블슈팅|##.*troubleshoot|##.*error'; then + pass "에러 처리/트러블슈팅 섹션 존재" +else + warn "에러 처리 섹션 없음 → 일반적인 실패 시나리오 문서화 권장" +fi + +# 하위 폴더 파일 참조 확인 (references/, templates/, examples/ 등) +SUB_FILES=$(find "$SKILL_DIR" -mindepth 2 -type f -name "*.md" 2>/dev/null) +if [ -n "$SUB_FILES" ]; then + UNLINKED=0 + while IFS= read -r sub_file; do + # SKILL_DIR 기준 상대 경로 (예: references/erd-syntax.md) + rel_path="${sub_file#$SKILL_DIR/}" + sub_basename=$(basename "$sub_file") + # 상대 경로 또는 파일명으로 참조 확인 + if ! grep -q "$sub_basename" "$SKILL_FILE"; then + warn "${rel_path}이 SKILL.md에서 참조되지 않음" + UNLINKED=$((UNLINKED + 1)) + fi + done <<< "$SUB_FILES" + if [ "$UNLINKED" -eq 0 ]; then + pass "모든 하위 폴더 파일이 SKILL.md에서 참조됨" + fi +fi + +echo "" + +# ═════════════════════════════════════════════ +# 결과 요약 +# ═════════════════════════════════════════════ +echo -e "${BOLD}${CYAN}═══════════════════════════════════════════${NC}" +echo -e "${BOLD} 검증 결과 요약${NC}" +echo -e "${CYAN}═══════════════════════════════════════════${NC}" +echo -e " ${GREEN}통과: ${PASS}개${NC}" +echo -e " ${RED}실패: ${FAIL}개${NC}" +echo -e " ${YELLOW}경고: ${WARN}개${NC}" +echo "" + +if [ "$FAIL" -eq 0 ] && [ "$WARN" -eq 0 ]; then + echo -e " ${GREEN}${BOLD}🎉 모든 검증 통과! 스킬이 배포 준비 완료되었습니다.${NC}" +elif [ "$FAIL" -eq 0 ]; then + echo -e " ${YELLOW}${BOLD}⚠ 실패 없음, 경고 ${WARN}개. 경고 항목 개선을 권장합니다.${NC}" +else + echo -e " ${RED}${BOLD}✗ 실패 ${FAIL}개 발견. 실패 항목을 수정한 후 재검증하세요.${NC}" +fi + +echo "" +exit $FAIL From 98c688da7dddd288a180203875c8f3b6c418c147 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 08:18:50 +0900 Subject: [PATCH 03/68] =?UTF-8?q?feat:=20Admin=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?interfaces/api/auth=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminAuthInterceptor 추가 (X-Loopers-Ldap 헤더 기반, /api-admin/** 경로) - 인증 컴포넌트 support/auth → interfaces/api/auth 패키지 이동 - MethodArgumentNotValidException 핸들러 추가 - auth.admin.ldap-credential 설정 추가 --- .../interfaces/api/ApiControllerAdvice.java | 10 ++++++ .../api/auth/AdminAuthInterceptor.java | 36 +++++++++++++++++++ .../api}/auth/AuthUser.java | 2 +- .../api}/auth/AuthUserResolver.java | 2 +- .../api}/auth/AuthenticatedUser.java | 2 +- .../interfaces/api/config/WebMvcConfig.java | 11 +++++- .../src/main/resources/application.yml | 4 +++ 7 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java rename apps/commerce-api/src/main/java/com/loopers/{support => interfaces/api}/auth/AuthUser.java (86%) rename apps/commerce-api/src/main/java/com/loopers/{support => interfaces/api}/auth/AuthUserResolver.java (97%) rename apps/commerce-api/src/main/java/com/loopers/{support => interfaces/api}/auth/AuthenticatedUser.java (82%) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 11611ad99..cce96ce26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -29,6 +30,15 @@ public ResponseEntity> handle(CoreException e) { return failureResponse(e.getErrorType(), e.getCustomMessage()); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getDefaultMessage()) + .findFirst() + .orElse("잘못된 요청입니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { String name = e.getName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java new file mode 100644 index 000000000..ffa28ec90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + + private final String ldapCredential; + + public AdminAuthInterceptor(@Value("${auth.admin.ldap-credential}") String ldapCredential) { + this.ldapCredential = ldapCredential; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String ldap = request.getHeader(HEADER_LDAP); + + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다"); + } + + if (!ldapCredential.equals(ldap)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다"); + } + + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUser.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUser.java index a66e38d48..54e5fb736 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUser.java @@ -1,4 +1,4 @@ -package com.loopers.support.auth; +package com.loopers.interfaces.api.auth; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java index 0db440553..eea76eb3b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java @@ -1,4 +1,4 @@ -package com.loopers.support.auth; +package com.loopers.interfaces.api.auth; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java similarity index 82% rename from apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java index 6e1ea4f6c..07de7cbdb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticatedUser.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -1,4 +1,4 @@ -package com.loopers.support.auth; +package com.loopers.interfaces.api.auth; import com.loopers.domain.user.User; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java index 7b69d157e..2d436e7a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java @@ -1,9 +1,11 @@ package com.loopers.interfaces.api.config; -import com.loopers.support.auth.AuthUserResolver; +import com.loopers.interfaces.api.auth.AdminAuthInterceptor; +import com.loopers.interfaces.api.auth.AuthUserResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -13,9 +15,16 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthUserResolver authUserResolver; + private final AdminAuthInterceptor adminAuthInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authUserResolver); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/**"); + } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..51c274e5b 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -24,6 +24,10 @@ spring: - logging.yml - monitoring.yml +auth: + admin: + ldap-credential: admin-ldap + springdoc: use-fqn: true swagger-ui: From 3f79f934a2a225ef56a4cbb9adf937c585682c97 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 09:03:05 +0900 Subject: [PATCH 04/68] =?UTF-8?q?fix:=20User=20API=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/interfaces/api/user/UserApiV1Spec.java | 2 +- .../com/loopers/interfaces/api/user/UserV1Controller.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java index b6a1bfcdf..1ccde54b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.user; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.auth.AuthenticatedUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 47fcfe50f..85eea13a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -3,8 +3,8 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.auth.AuthUser; -import com.loopers.support.auth.AuthenticatedUser; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; From b526baa9c6f46eb374c8b3ed6e1e71a551648c8d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 09:04:50 +0900 Subject: [PATCH 05/68] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 18 +++ .../loopers/application/brand/BrandInfo.java | 29 ++++ .../java/com/loopers/domain/brand/Brand.java | 56 +++++++ .../loopers/domain/brand/BrandRepository.java | 12 ++ .../loopers/domain/brand/BrandService.java | 25 +++ .../brand/BrandJpaRepository.java | 9 ++ .../brand/BrandRepositoryImpl.java | 30 ++++ .../api/brand/BrandAdminApiV1Spec.java | 17 +++ .../api/brand/BrandAdminV1Controller.java | 27 ++++ .../interfaces/api/brand/BrandAdminV1Dto.java | 39 +++++ .../brand/BrandServiceIntegrationTest.java | 77 ++++++++++ .../com/loopers/domain/brand/BrandTest.java | 91 +++++++++++ .../api/brand/BrandAdminApiE2ETest.java | 143 ++++++++++++++++++ 13 files changed, 573 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..eaa868199 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,18 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandFacade { + + private final BrandService brandService; + + public BrandInfo register(String name, String description) { + Brand brand = brandService.register(name, description); + return BrandInfo.from(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..75655f0e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.LocalDateTime; + +public record BrandInfo( + Long id, + String name, + String description, + Status status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public enum Status { + ACTIVE, DELETED + } + + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.isDeleted() ? Status.DELETED : Status.ACTIVE, + brand.getCreatedAt().toLocalDateTime(), + brand.getUpdatedAt().toLocalDateTime() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..27be2f1b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,56 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "brands") +@Getter +public class Brand extends BaseEntity { + + private static final int NAME_MAX_LENGTH = 100; + private static final int DESCRIPTION_MAX_LENGTH = 500; + + @Column(nullable = false, unique = true) + private String name; + + @Column(length = DESCRIPTION_MAX_LENGTH) + private String description; + + protected Brand() {} + + private Brand(String name, String description) { + this.name = name; + this.description = description; + } + + public static Brand create(String name, String description) { + validateName(name); + validateDescription(description); + return new Brand(name, description); + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수입니다"); + } + if (name.length() > NAME_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 100자 이하여야 합니다"); + } + } + + private static void validateDescription(String description) { + if (description != null && description.length() > DESCRIPTION_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 설명은 500자 이하여야 합니다"); + } + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..09d4e55c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + boolean existsByName(String name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..e4bf385d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public Brand register(String name, String description) { + if (brandRepository.existsByName(name)) { + throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); + } + + Brand brand = Brand.create(name, description); + return brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..41f0618be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..dc0f56e6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByName(name); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java new file mode 100644 index 000000000..727211022 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand Admin API", description = "브랜드 관리 API") +public interface BrandAdminApiV1Spec { + + @Operation( + summary = "브랜드 등록", + description = "새로운 입점 브랜드를 등록합니다." + ) + ApiResponse register( + BrandAdminV1Dto.RegisterRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..775df5196 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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-admin/v1/brands") +@RequiredArgsConstructor +public class BrandAdminV1Controller implements BrandAdminApiV1Spec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody BrandAdminV1Dto.RegisterRequest request) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java new file mode 100644 index 000000000..77ed7b1c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public class BrandAdminV1Dto { + + public record RegisterRequest( + @NotBlank(message = "브랜드명은 필수입니다") + @Size(max = 100, message = "브랜드명은 100자 이하여야 합니다") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), + info.name(), + info.description(), + info.status().name(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..6d7a67e79 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 브랜드_등록 { + + @Test + void 유효한_정보로_등록하면_브랜드가_생성된다() { + Brand result = brandService.register("나이키", "스포츠 브랜드"); + + assertThat(result.getId()).isNotNull(); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @Test + void 설명_없이_등록하면_브랜드가_생성된다() { + Brand result = brandService.register("나이키", null); + + assertThat(result.getId()).isNotNull(); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isNull(); + } + + @Test + void 이미_존재하는_브랜드명으로_등록하면_예외() { + brandService.register("나이키", "스포츠 브랜드"); + + assertThatThrownBy(() -> brandService.register("나이키", "다른 설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) + .hasMessageContaining("이미 등록된 브랜드입니다"); + } + + @Test + void 삭제된_브랜드와_동일한_이름으로_등록하면_예외() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + brand.delete(); + brandRepository.save(brand); + + assertThatThrownBy(() -> brandService.register("나이키", "새 설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) + .hasMessageContaining("이미 등록된 브랜드입니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..b1619bcd6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BrandTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_브랜드가_생성된다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @Test + void 설명이_null이면_null로_생성된다() { + Brand brand = Brand.create("나이키", null); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isNull(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 브랜드명이_null_또는_빈값이면_예외(String name) { + assertThatThrownBy(() -> Brand.create(name, "설명")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수입니다"); + } + + @Test + void 브랜드명이_100자를_초과하면_예외() { + String longName = "a".repeat(101); + + assertThatThrownBy(() -> Brand.create(longName, "설명")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); + } + + @Test + void 브랜드명이_100자이면_성공() { + String maxName = "a".repeat(100); + + assertThatCode(() -> Brand.create(maxName, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 설명이_500자를_초과하면_예외() { + String longDescription = "a".repeat(501); + + assertThatThrownBy(() -> Brand.create("나이키", longDescription)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); + } + + @Test + void 설명이_500자이면_성공() { + String maxDescription = "a".repeat(500); + + assertThatCode(() -> Brand.create("나이키", maxDescription)) + .doesNotThrowAnyException(); + } + } + + @Nested + class 삭제_상태_확인 { + + @Test + void 삭제하면_삭제_상태이다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + brand.delete(); + + assertThat(brand.isDeleted()).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java new file mode 100644 index 000000000..619f1bd1b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -0,0 +1,143 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BrandAdminApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/brands"; + private static final String VALID_LDAP = "admin-ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 브랜드_등록 { + + @Test + void 유효한_정보로_등록하면_브랜드_정보가_반환된다() { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + + ResponseEntity> response = postRegister(request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull() + ); + } + + @Test + void 브랜드는_활성_상태로_생성된다() { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", null); + + ResponseEntity> response = postRegister(request); + + assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"); + } + + @Test + void 이미_존재하는_브랜드명이면_409_응답() { + postRegister(new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드")); + + BrandAdminV1Dto.RegisterRequest duplicateRequest = new BrandAdminV1Dto.RegisterRequest("나이키", "다른 설명"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(duplicateRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 브랜드명이_빈값이면_400_응답() { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("", "설명"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증헤더가_누락되면_401_응답() { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증에_실패하면_401_응답() { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // --- 헬퍼 메서드 --- + + private ResponseEntity> postRegister(BrandAdminV1Dto.RegisterRequest request) { + return testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } +} From 7c35cf6504177c76477b188de6a27ed81bb9f9b1 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 10:13:51 +0900 Subject: [PATCH 06/68] =?UTF-8?q?chore:=20rules=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20-=20=EA=B3=84=EC=B8=B5=EB=B3=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EB=9E=B5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Service=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/testing.md | 9 +++++++++ .claude/rules/project/archtecture.md | 9 ++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.claude/rules/conventions/testing.md b/.claude/rules/conventions/testing.md index 4ff9ce1fa..32e823ef1 100644 --- a/.claude/rules/conventions/testing.md +++ b/.claude/rules/conventions/testing.md @@ -5,6 +5,15 @@ - 통합 테스트: Service + Repository (TestContainers) - E2E 테스트: API 엔드포인트 전체 흐름 +## 계층별 테스트 전략 + +| 계층 | 테스트 유형 | 이유 | +|------|-----------|------| +| Entity, VO, Domain Service | 단위 테스트 | 순수 객체, Mock 없이 빠른 피드백 | +| Service | 통합 테스트 | Repository를 통한 DB 검증(중복 체크, 존재 확인)이 핵심 | +| Facade | 별도 테스트 없음 | 얇은 오케스트레이션 — Service 통합 + E2E로 커버 | +| Controller | E2E 테스트 | HTTP 요청/응답, 상태 코드, 인증 전체 흐름 검증 | + ## 테스트 유틸리티 - `DatabaseCleanUp`: 테스트마다 테이블 정리 - TestContainers: 실제 MySQL, Redis, Kafka 인스턴스 제공 diff --git a/.claude/rules/project/archtecture.md b/.claude/rules/project/archtecture.md index e55782213..096fcf7cd 100644 --- a/.claude/rules/project/archtecture.md +++ b/.claude/rules/project/archtecture.md @@ -49,12 +49,15 @@ interfaces → application → domain ← infrastructure - 다른 도메인의 **Service만** 호출 (Repository 직접 호출 금지) ### Service (domain) — 자기 도메인의 연산 캡슐화 -- **조회 메서드**: 비즈니스 의미를 가진 이름으로 제공 (`getActiveUser`, `getActiveProduct` 등) -- **명령 메서드**: 조회+판단+실행을 하나의 비즈니스 연산으로 캡슐화 +- **조회 메서드**: 범용 대상 식별 (getActiveBrand, getActiveUser 등) + - 여러 비즈니스 연산에서 공통으로 사용되는 조회 +- **명령 메서드**: Entity를 받아서 도메인 연산 수행 (판단+실행) + - 연산에 필요한 DB 조회(중복 확인, 존재 여부 등)는 Service 내부에서 처리 + - 단, 대상 식별 자체가 연산과 불가분인 경우 Service 내부에서 조회 포함 + (예: unlike — 존재 여부 확인이 곧 연산의 전제조건) - 자기 도메인의 Repository + Domain Service만 사용 - **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) - Facade에 도메인 내부 구조(Optional, 상태값, Entity 컬렉션) 노출 최소화 -- `@Transactional` 사용하지 않음 (트랜잭션은 Facade 책임) ### Domain Service (domain) — 필요할 때만 생성 From 2472047dacb07e58b24492ab91ce80dbc7229e1e Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 10:43:36 +0900 Subject: [PATCH 07/68] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 8 ++ .../java/com/loopers/domain/brand/Brand.java | 19 ++- .../loopers/domain/brand/BrandRepository.java | 2 + .../loopers/domain/brand/BrandService.java | 16 ++- .../brand/BrandJpaRepository.java | 2 + .../brand/BrandRepositoryImpl.java | 5 + .../api/brand/BrandAdminApiV1Spec.java | 9 ++ .../api/brand/BrandAdminV1Controller.java | 11 ++ .../interfaces/api/brand/BrandAdminV1Dto.java | 8 ++ .../brand/BrandServiceIntegrationTest.java | 80 +++++++++++ .../com/loopers/domain/brand/BrandTest.java | 64 +++++++++ .../api/brand/BrandAdminApiE2ETest.java | 131 ++++++++++++++++++ 12 files changed, 350 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index eaa868199..2c3d0bbff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -4,6 +4,7 @@ import com.loopers.domain.brand.BrandService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -15,4 +16,11 @@ public BrandInfo register(String name, String description) { Brand brand = brandService.register(name, description); return BrandInfo.from(brand); } + + @Transactional + public BrandInfo update(Long brandId, String name, String description) { + Brand brand = brandService.getActiveBrand(brandId); + brandService.update(brand, name, description); + return BrandInfo.from(brand); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 27be2f1b6..db4d00e16 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -35,6 +35,21 @@ public static Brand create(String name, String description) { return new Brand(name, description); } + public void update(String name, String description) { + if (name != null) { + validateName(name); + this.name = name; + } + if (description != null) { + validateDescription(description); + this.description = description; + } + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } + private static void validateName(String name) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수입니다"); @@ -49,8 +64,4 @@ private static void validateDescription(String description) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 설명은 500자 이하여야 합니다"); } } - - public boolean isDeleted() { - return getDeletedAt() != null; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 09d4e55c8..308999eae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -9,4 +9,6 @@ public interface BrandRepository { Optional findById(Long id); boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index e4bf385d4..b7149d3b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -14,7 +14,7 @@ public class BrandService { private final BrandRepository brandRepository; @Transactional - public Brand register(String name, String description) { +public Brand register(String name, String description) { if (brandRepository.existsByName(name)) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } @@ -22,4 +22,18 @@ public Brand register(String name, String description) { Brand brand = Brand.create(name, description); return brandRepository.save(brand); } + + public Brand getActiveBrand(Long brandId) { + return brandRepository.findById(brandId) + .filter(brand -> !brand.isDeleted()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); + } + + public void update(Brand brand, String name, String description) { + if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { + throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); + } + + brand.update(name, description); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 41f0618be..0f436a536 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -6,4 +6,6 @@ public interface BrandJpaRepository extends JpaRepository { boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index dc0f56e6d..e0973416b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -27,4 +27,9 @@ public Optional findById(Long id) { public boolean existsByName(String name) { return brandJpaRepository.existsByName(name); } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return brandJpaRepository.existsByNameAndIdNot(name, id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index 727211022..422919115 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -14,4 +14,13 @@ public interface BrandAdminApiV1Spec { ApiResponse register( BrandAdminV1Dto.RegisterRequest request ); + + @Operation( + summary = "브랜드 수정", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse update( + Long brandId, + BrandAdminV1Dto.UpdateRequest request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 775df5196..5b3e5de5a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -5,6 +5,8 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,4 +26,13 @@ public ApiResponse register( BrandInfo info = brandFacade.register(request.name(), request.description()); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } + + @PatchMapping("/{brandId}") + @Override + public ApiResponse update( + @PathVariable Long brandId, + @Valid @RequestBody BrandAdminV1Dto.UpdateRequest request) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java index 77ed7b1c2..6003f4e06 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -17,6 +17,14 @@ public record RegisterRequest( String description ) {} + public record UpdateRequest( + @Size(min = 1, max = 100, message = "브랜드명은 1~100자여야 합니다") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") + String description + ) {} + public record BrandResponse( Long id, String name, diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 6d7a67e79..556d4e587 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -74,4 +74,84 @@ class 브랜드_등록 { .hasMessageContaining("이미 등록된 브랜드입니다"); } } + + @Nested + class 브랜드_수정 { + + @Test + void 유효한_정보로_수정하면_성공한다() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + + Brand activeBrand = brandService.getActiveBrand(brand.getId()); + brandService.update(activeBrand, "아디다스", "독일 스포츠 브랜드"); + + assertThat(activeBrand.getName()).isEqualTo("아디다스"); + assertThat(activeBrand.getDescription()).isEqualTo("독일 스포츠 브랜드"); + } + + @Test + void 다른_브랜드와_이름이_중복이면_예외() { + brandService.register("나이키", "스포츠 브랜드"); + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + + Brand activeBrand = brandService.getActiveBrand(adidas.getId()); + + assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) + .hasMessageContaining("이미 등록된 브랜드입니다"); + } + + @Test + void 삭제된_브랜드와_이름이_중복이면_예외() { + Brand nike = brandService.register("나이키", "스포츠 브랜드"); + nike.delete(); + brandRepository.save(nike); + + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + + Brand activeBrand = brandService.getActiveBrand(adidas.getId()); + + assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) + .hasMessageContaining("이미 등록된 브랜드입니다"); + } + + @Test + void 자기_자신_이름으로_수정하면_정상_처리된다() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + + Brand activeBrand = brandService.getActiveBrand(brand.getId()); + brandService.update(activeBrand, "나이키", "변경된 설명"); + + assertThat(activeBrand.getName()).isEqualTo("나이키"); + assertThat(activeBrand.getDescription()).isEqualTo("변경된 설명"); + } + + } + + @Nested + class 활성_브랜드_조회 { + + @Test + void 미존재_브랜드면_예외() { + assertThatThrownBy(() -> brandService.getActiveBrand(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + + @Test + void 삭제된_브랜드면_예외() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + brand.delete(); + brandRepository.save(brand); + + assertThatThrownBy(() -> brandService.getActiveBrand(brand.getId())) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index b1619bcd6..6de9fb163 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -77,6 +77,70 @@ class 생성 { } } + @Nested + class 수정 { + + @Test + void name만_수정하면_name만_변경된다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + brand.update("아디다스", null); + + assertThat(brand.getName()).isEqualTo("아디다스"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @Test + void description만_수정하면_description만_변경된다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + brand.update(null, "독일 스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); + } + + @Test + void 둘_다_수정하면_둘_다_변경된다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + brand.update("아디다스", "독일 스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("아디다스"); + assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void name이_빈값이면_예외(String name) { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + assertThatThrownBy(() -> brand.update(name, null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수입니다"); + } + + @Test + void name이_100자를_초과하면_예외() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + String longName = "a".repeat(101); + + assertThatThrownBy(() -> brand.update(longName, null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); + } + + @Test + void description이_500자를_초과하면_예외() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + String longDescription = "a".repeat(501); + + assertThatThrownBy(() -> brand.update(null, longDescription)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); + } + } + @Nested class 삭제_상태_확인 { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 619f1bd1b..96270ab6a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -125,8 +125,131 @@ class 브랜드_등록 { } } + @Nested + class 브랜드_수정 { + + @Test + void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("아디다스", "독일 스포츠 브랜드"); + + ResponseEntity> response = patchUpdate(brandId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("아디다스"), + () -> assertThat(response.getBody().data().description()).isEqualTo("독일 스포츠 브랜드") + ); + } + + @Test + void name만_보내면_name만_수정된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("아디다스", null); + + ResponseEntity> response = patchUpdate(brandId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("아디다스"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드") + ); + } + + @Test + void description만_보내면_description만_수정된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest(null, "변경된 설명"); + + ResponseEntity> response = patchUpdate(brandId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("변경된 설명") + ); + } + + @Test + void 중복_브랜드명이면_409_응답() { + registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = registerBrand("아디다스", "독일 스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + adidasId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 미존재_브랜드면_404_응답() { + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 입력_규칙_위반_시_400_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("", null); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_누락이면_401_응답() { + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.PATCH, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증_실패이면_401_응답() { + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + // --- 헬퍼 메서드 --- + private Long registerBrand(String name, String description) { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + ResponseEntity> response = postRegister(request); + return response.getBody().data().id(); + } + private ResponseEntity> postRegister(BrandAdminV1Dto.RegisterRequest request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, @@ -135,6 +258,14 @@ private ResponseEntity> postRegister( ); } + private ResponseEntity> patchUpdate(Long brandId, BrandAdminV1Dto.UpdateRequest request) { + return testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); From ede01ab5e011c01414b752cd8905075a1eccbf28 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 11:37:33 +0900 Subject: [PATCH 08/68] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 6 +++ .../loopers/domain/brand/BrandService.java | 5 ++ .../api/brand/BrandAdminApiV1Spec.java | 6 +++ .../api/brand/BrandAdminV1Controller.java | 8 +++ .../brand/BrandServiceIntegrationTest.java | 14 +++++ .../api/brand/BrandAdminApiE2ETest.java | 53 +++++++++++++++++++ 6 files changed, 92 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 2c3d0bbff..ba7bdd19c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -17,6 +17,12 @@ public BrandInfo register(String name, String description) { return BrandInfo.from(brand); } + @Transactional + public void delete(Long brandId) { + Brand brand = brandService.getActiveBrand(brandId); + brandService.delete(brand); + } + @Transactional public BrandInfo update(Long brandId, String name, String description) { Brand brand = brandService.getActiveBrand(brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index b7149d3b0..e0a6afbe7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -29,6 +29,11 @@ public Brand getActiveBrand(Long brandId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); } + @Transactional + public void delete(Brand brand) { + brand.delete(); + } + public void update(Brand brand, String name, String description) { if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index 422919115..c54eff4a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -23,4 +23,10 @@ ApiResponse update( Long brandId, BrandAdminV1Dto.UpdateRequest request ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다." + ) + ApiResponse delete(Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 5b3e5de5a..03f16c475 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -5,6 +5,7 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -35,4 +36,11 @@ public ApiResponse update( BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete(@PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 556d4e587..325dc85d5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -131,6 +131,20 @@ class 브랜드_수정 { } + @Nested + class 브랜드_삭제 { + + @Test + void 활성_브랜드를_삭제하면_삭제_상태로_변경된다() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + + Brand activeBrand = brandService.getActiveBrand(brand.getId()); + brandService.delete(activeBrand); + + assertThat(activeBrand.isDeleted()).isTrue(); + } + } + @Nested class 활성_브랜드_조회 { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 96270ab6a..645be5222 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -242,6 +242,51 @@ class 브랜드_수정 { } } + @Nested + class 브랜드_삭제 { + + @Test + void 활성_브랜드를_삭제하면_200_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + + ResponseEntity> response = deleteRequest(brandId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 미존재_브랜드면_404_응답() { + ResponseEntity> response = deleteRequest(999L); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 인증헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.DELETE, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { @@ -266,6 +311,14 @@ private ResponseEntity> patchUpdate(L ); } + private ResponseEntity> deleteRequest(Long brandId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); From e84eb546983ca240929b3d12963f9497a7253627 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 12:39:27 +0900 Subject: [PATCH 09/68] =?UTF-8?q?chore:=20rules=20=EC=A0=95=EB=A6=AC=20-?= =?UTF-8?q?=20architecture=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?/=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/validation.md | 20 ++++++++ .../{archtecture.md => architecture.md} | 50 +++++++++---------- 2 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 .claude/rules/conventions/validation.md rename .claude/rules/project/{archtecture.md => architecture.md} (72%) diff --git a/.claude/rules/conventions/validation.md b/.claude/rules/conventions/validation.md new file mode 100644 index 000000000..6254afc69 --- /dev/null +++ b/.claude/rules/conventions/validation.md @@ -0,0 +1,20 @@ +# 검증 및 예외 처리 + +## 검증 위치와 역할 + +| 위치 | 역할 | 예시 | +|---|---|---| +| `@Valid` (DTO) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | +| Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | +| Service | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | + +- `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) +- Entity가 검증의 최종 방어선 — DTO 검증이 빠져도 Entity에서 반드시 잡아야 함 + +## 예외 처리 + +- 비즈니스 예외는 `CoreException`으로 통일 +- `CoreException(ErrorType, message)` 형태로 사용 +- 글로벌 핸들러(`@RestControllerAdvice`)에서 일괄 처리 +- Controller에서 try-catch 금지 +- ErrorType은 HTTP 상태코드와 매핑되는 enum으로 관리 (`BAD_REQUEST`, `NOT_FOUND`, `FORBIDDEN` 등) diff --git a/.claude/rules/project/archtecture.md b/.claude/rules/project/architecture.md similarity index 72% rename from .claude/rules/project/archtecture.md rename to .claude/rules/project/architecture.md index 096fcf7cd..9cde6ebfa 100644 --- a/.claude/rules/project/archtecture.md +++ b/.claude/rules/project/architecture.md @@ -23,7 +23,7 @@ interfaces → application → domain ← infrastructure - **Rich Domain Model**: 비즈니스 로직과 도메인 불변식은 Entity에 - **Service**: 자기 도메인의 연산 캡슐화 (Repository 접근 + Entity/Domain Service 위임) - **Facade**: 여러 도메인 Service 간 오케스트레이션, 트랜잭션 경계 -- **Repository 패턴**: 인터페이스는 `domain/`, 구현체는 `infrastructure/ +- **Repository 패턴**: 인터페이스는 `domain/`, 구현체는 `infrastructure/` - **DTO**: Java record 사용 (불변 보장) - **Soft Delete**: `deletedAt` 필드 사용 @@ -48,9 +48,14 @@ interfaces → application → domain ← infrastructure - Domain Entity → Info DTO 변환 - 다른 도메인의 **Service만** 호출 (Repository 직접 호출 금지) -### Service (domain) — 자기 도메인의 연산 캡슐화 -- **조회 메서드**: 범용 대상 식별 (getActiveBrand, getActiveUser 등) - - 여러 비즈니스 연산에서 공통으로 사용되는 조회 +### Service (domain) — 명령 흐름 +- 자기 도메인의 연산 캡슐화 +- **조회 메서드**: 대상 식별만 수행 + - 쿼리에는 식별 조건만 사용한다 + - 식별 조건: 조건을 제거하면 다른 Entity가 조회되는 것 (PK, FK, 복합키, 유니크 관계 등) + - 예: `findById`, `findByUserIdAndProductId`, `findAllByIdIn` + - 동시성 제어가 필요한 경우 락 조회 허용 (`findByIdForUpdate` 등) + - **상태 조건(deletedAt, status 등)은 쿼리에 포함하지 않는다** — 상태 검증은 Entity 책임 - **명령 메서드**: Entity를 받아서 도메인 연산 수행 (판단+실행) - 연산에 필요한 DB 조회(중복 확인, 존재 여부 등)는 Service 내부에서 처리 - 단, 대상 식별 자체가 연산과 불가분인 경우 Service 내부에서 조회 포함 @@ -59,6 +64,20 @@ interfaces → application → domain ← infrastructure - **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) - Facade에 도메인 내부 구조(Optional, 상태값, Entity 컬렉션) 노출 최소화 +#### 식별 조건 vs 상태 조건 판별 기준 + +> **"이 조건을 빼면 다른 Entity가 조회되는가?"** + +- 빼면 다른 Entity가 나온다 → **식별 조건** → 쿼리에 포함 +- 빼도 같은 Entity인데 더 많이 나온다 → **상태 조건** → Entity가 판단 + +### QueryService (domain) — 조회 흐름 + +- 표현을 위한 조회 전용 서비스 +- 상태 필터링, 정렬, 페이징 등 쿼리 조건에 포함 가능 +- 예: `getActiveBrand`, `getActiveProducts`, `findOrdersByStatus` +- 조회가 단순한 도메인은 Service에 조회 메서드로 포함해도 무방 +- 조회가 복잡해지는 시점(정렬, 페이징, 검색 조건, DTO 프로젝션)에서 분리 ### Domain Service (domain) — 필요할 때만 생성 - 단일 Entity로 해결 안 되는 비즈니스 로직 @@ -79,25 +98,6 @@ interfaces → application → domain ← infrastructure ### 트랜잭션 전략 - **Service**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 -- **Facade**: `@Transactional`로 여러 Service를 하나의 트랜잭션으로 묶음 +- **Facade**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 + - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 - Facade가 있으면 Service의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) - -# 검증 전략 - -### 검증 위치와 역할 - -| 위치 | 역할 | 예시 | -|---|---|---| -| `@Valid` (DTO) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | -| Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | -| Service | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | - -- `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) -- Entity가 검증의 최종 방어선 — DTO 검증이 빠져도 Entity에서 반드시 잡아야 함 - -## 예외 처리 -- 비즈니스 예외는 `CoreException`으로 통일 -- `CoreException(ErrorType, message)` 형태로 사용 -- 글로벌 핸들러(`@RestControllerAdvice`)에서 일괄 처리 -- Controller에서 try-catch 금지 -- ErrorType은 HTTP 상태코드와 매핑되는 enum으로 관리 (`BAD_REQUEST`, `NOT_FOUND`, `FORBIDDEN` 등) \ No newline at end of file From 70ef696b721859bd5c35a1f5a4d428d163160295 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 12:59:05 +0900 Subject: [PATCH 10/68] =?UTF-8?q?refactor:=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20Entity=20=EC=B1=85=EC=9E=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 4 +-- .../java/com/loopers/domain/brand/Brand.java | 13 ++++++++ .../loopers/domain/brand/BrandService.java | 3 +- .../brand/BrandServiceIntegrationTest.java | 33 +++++++------------ .../com/loopers/domain/brand/BrandTest.java | 22 ++++++++++++- .../api/brand/BrandAdminApiE2ETest.java | 9 ----- 6 files changed, 49 insertions(+), 35 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index ba7bdd19c..d2604129f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -19,13 +19,13 @@ public BrandInfo register(String name, String description) { @Transactional public void delete(Long brandId) { - Brand brand = brandService.getActiveBrand(brandId); + Brand brand = brandService.getBrand(brandId); brandService.delete(brand); } @Transactional public BrandInfo update(Long brandId, String name, String description) { - Brand brand = brandService.getActiveBrand(brandId); + Brand brand = brandService.getBrand(brandId); brandService.update(brand, name, description); return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index db4d00e16..e78622d4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -36,6 +36,7 @@ public static Brand create(String name, String description) { } public void update(String name, String description) { + validateNotDeleted(); if (name != null) { validateName(name); this.name = name; @@ -46,10 +47,22 @@ public void update(String name, String description) { } } + @Override + public void delete() { + validateNotDeleted(); + super.delete(); + } + public boolean isDeleted() { return getDeletedAt() != null; } + public void validateNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다"); + } + } + private static void validateName(String name) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수입니다"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index e0a6afbe7..60eaa8ddd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -23,9 +23,8 @@ public Brand register(String name, String description) { return brandRepository.save(brand); } - public Brand getActiveBrand(Long brandId) { + public Brand getBrand(Long brandId) { return brandRepository.findById(brandId) - .filter(brand -> !brand.isDeleted()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 325dc85d5..7c8c6019b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -43,15 +43,6 @@ class 브랜드_등록 { assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); } - @Test - void 설명_없이_등록하면_브랜드가_생성된다() { - Brand result = brandService.register("나이키", null); - - assertThat(result.getId()).isNotNull(); - assertThat(result.getName()).isEqualTo("나이키"); - assertThat(result.getDescription()).isNull(); - } - @Test void 이미_존재하는_브랜드명으로_등록하면_예외() { brandService.register("나이키", "스포츠 브랜드"); @@ -82,7 +73,7 @@ class 브랜드_수정 { void 유효한_정보로_수정하면_성공한다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getActiveBrand(brand.getId()); + Brand activeBrand = brandService.getBrand(brand.getId()); brandService.update(activeBrand, "아디다스", "독일 스포츠 브랜드"); assertThat(activeBrand.getName()).isEqualTo("아디다스"); @@ -94,7 +85,7 @@ class 브랜드_수정 { brandService.register("나이키", "스포츠 브랜드"); Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); - Brand activeBrand = brandService.getActiveBrand(adidas.getId()); + Brand activeBrand = brandService.getBrand(adidas.getId()); assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) .isInstanceOf(CoreException.class) @@ -110,7 +101,7 @@ class 브랜드_수정 { Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); - Brand activeBrand = brandService.getActiveBrand(adidas.getId()); + Brand activeBrand = brandService.getBrand(adidas.getId()); assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) .isInstanceOf(CoreException.class) @@ -122,7 +113,7 @@ class 브랜드_수정 { void 자기_자신_이름으로_수정하면_정상_처리된다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getActiveBrand(brand.getId()); + Brand activeBrand = brandService.getBrand(brand.getId()); brandService.update(activeBrand, "나이키", "변경된 설명"); assertThat(activeBrand.getName()).isEqualTo("나이키"); @@ -138,7 +129,7 @@ class 브랜드_삭제 { void 활성_브랜드를_삭제하면_삭제_상태로_변경된다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getActiveBrand(brand.getId()); + Brand activeBrand = brandService.getBrand(brand.getId()); brandService.delete(activeBrand); assertThat(activeBrand.isDeleted()).isTrue(); @@ -146,26 +137,26 @@ class 브랜드_삭제 { } @Nested - class 활성_브랜드_조회 { + class 브랜드_조회 { @Test void 미존재_브랜드면_예외() { - assertThatThrownBy(() -> brandService.getActiveBrand(999L)) + assertThatThrownBy(() -> brandService.getBrand(999L)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); } @Test - void 삭제된_브랜드면_예외() { + void 삭제된_브랜드도_조회된다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); brand.delete(); brandRepository.save(brand); - assertThatThrownBy(() -> brandService.getActiveBrand(brand.getId())) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) - .hasMessageContaining("존재하지 않는 브랜드입니다"); + Brand result = brandService.getBrand(brand.getId()); + + assertThat(result.getId()).isEqualTo(brand.getId()); + assertThat(result.isDeleted()).isTrue(); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index 6de9fb163..5af7b7441 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -142,7 +142,7 @@ class 수정 { } @Nested - class 삭제_상태_확인 { + class 삭제 { @Test void 삭제하면_삭제_상태이다() { @@ -151,5 +151,25 @@ class 삭제_상태_확인 { assertThat(brand.isDeleted()).isTrue(); } + + @Test + void 삭제된_브랜드를_삭제하면_예외() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + brand.delete(); + + assertThatThrownBy(brand::delete) + .isInstanceOf(CoreException.class) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + + @Test + void 삭제된_브랜드를_수정하면_예외() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + brand.delete(); + + assertThatThrownBy(() -> brand.update("아디다스", "독일 스포츠 브랜드")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 645be5222..4461431e5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -58,15 +58,6 @@ class 브랜드_등록 { ); } - @Test - void 브랜드는_활성_상태로_생성된다() { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", null); - - ResponseEntity> response = postRegister(request); - - assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"); - } - @Test void 이미_존재하는_브랜드명이면_409_응답() { postRegister(new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드")); From f9c3abf1c38d5b2af25ff78b12660fe8642e7237 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 15:55:36 +0900 Subject: [PATCH 11/68] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 24 +- .../loopers/application/brand/BrandInfo.java | 6 +- .../loopers/domain/brand/BrandRepository.java | 9 + .../loopers/domain/brand/BrandService.java | 29 ++- .../brand/BrandJpaRepository.java | 15 ++ .../brand/BrandRepositoryImpl.java | 11 + .../loopers/interfaces/api/PageResponse.java | 37 +++ .../api/brand/BrandAdminApiV1Spec.java | 19 ++ .../api/brand/BrandAdminV1Controller.java | 24 ++ .../interfaces/api/brand/BrandAdminV1Dto.java | 50 +++- .../brand/BrandServiceIntegrationTest.java | 81 ++++++ .../api/brand/BrandAdminApiE2ETest.java | 244 ++++++++++++++++++ 12 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index d2604129f..90a2327b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -3,30 +3,48 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor +@Transactional(readOnly = true) public class BrandFacade { private final BrandService brandService; + // Command + + @Transactional public BrandInfo register(String name, String description) { Brand brand = brandService.register(name, description); return BrandInfo.from(brand); } + @Transactional + public BrandInfo update(Long brandId, String name, String description) { + Brand brand = brandService.getBrand(brandId); + brandService.update(brand, name, description); + return BrandInfo.from(brand); + } + @Transactional public void delete(Long brandId) { Brand brand = brandService.getBrand(brandId); brandService.delete(brand); } - @Transactional - public BrandInfo update(Long brandId, String name, String description) { + // Query + + public Page getList(String name, Boolean deleted, Pageable pageable) { + Page brands = brandService.findBrands(name, deleted, pageable); + return brands.map(BrandInfo::from); + } + + public BrandInfo getDetail(Long brandId) { Brand brand = brandService.getBrand(brandId); - brandService.update(brand, name, description); return BrandInfo.from(brand); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java index 75655f0e2..e2657b269 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -10,7 +10,8 @@ public record BrandInfo( String description, Status status, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + LocalDateTime deletedAt ) { public enum Status { ACTIVE, DELETED @@ -23,7 +24,8 @@ public static BrandInfo from(Brand brand) { brand.getDescription(), brand.isDeleted() ? Status.DELETED : Status.ACTIVE, brand.getCreatedAt().toLocalDateTime(), - brand.getUpdatedAt().toLocalDateTime() + brand.getUpdatedAt().toLocalDateTime(), + brand.getDeletedAt() != null ? brand.getDeletedAt().toLocalDateTime() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 308999eae..d33cad72c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -1,14 +1,23 @@ package com.loopers.domain.brand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.Optional; public interface BrandRepository { + // Command + Brand save(Brand brand); + // Query + Optional findById(Long id); boolean existsByName(String name); boolean existsByNameAndIdNot(String name, Long id); + + Page findAll(String name, Boolean deleted, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 60eaa8ddd..e6880a8ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,8 +15,10 @@ public class BrandService { private final BrandRepository brandRepository; + // Command + @Transactional -public Brand register(String name, String description) { + public Brand register(String name, String description) { if (brandRepository.existsByName(name)) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } @@ -23,9 +27,13 @@ public Brand register(String name, String description) { return brandRepository.save(brand); } - public Brand getBrand(Long brandId) { - return brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); + @Transactional + public void update(Brand brand, String name, String description) { + if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { + throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); + } + + brand.update(name, description); } @Transactional @@ -33,11 +41,14 @@ public void delete(Brand brand) { brand.delete(); } - public void update(Brand brand, String name, String description) { - if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { - throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); - } + // Query - brand.update(name, description); + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); + } + + public Page findBrands(String name, Boolean deleted, Pageable pageable) { + return brandRepository.findAll(name, deleted, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 0f436a536..25f5aaadb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -1,11 +1,26 @@ package com.loopers.infrastructure.brand; import com.loopers.domain.brand.Brand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface BrandJpaRepository extends JpaRepository { + // Query + boolean existsByName(String name); boolean existsByNameAndIdNot(String name, Long id); + + @Query(value = "SELECT b FROM Brand b " + + "WHERE (:name IS NULL OR b.name LIKE %:name%) " + + "AND (:deleted IS NULL OR (:deleted = true AND b.deletedAt IS NOT NULL) OR (:deleted = false AND b.deletedAt IS NULL)) " + + "ORDER BY b.createdAt DESC", + countQuery = "SELECT COUNT(b) FROM Brand b " + + "WHERE (:name IS NULL OR b.name LIKE %:name%) " + + "AND (:deleted IS NULL OR (:deleted = true AND b.deletedAt IS NOT NULL) OR (:deleted = false AND b.deletedAt IS NULL))") + Page findAll(@Param("name") String name, @Param("deleted") Boolean deleted, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index e0973416b..1369f9410 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -3,6 +3,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -13,11 +15,15 @@ public class BrandRepositoryImpl implements BrandRepository { private final BrandJpaRepository brandJpaRepository; + // Command + @Override public Brand save(Brand brand) { return brandJpaRepository.save(brand); } + // Query + @Override public Optional findById(Long id) { return brandJpaRepository.findById(id); @@ -32,4 +38,9 @@ public boolean existsByName(String name) { public boolean existsByNameAndIdNot(String name, Long id) { return brandJpaRepository.existsByNameAndIdNot(name, id); } + + @Override + public Page findAll(String name, Boolean deleted, Pageable pageable) { + return brandJpaRepository.findAll(name, deleted, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java new file mode 100644 index 000000000..d8243c507 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api; + +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.function.Function; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + + public static PageResponse from(Page page, Function mapper) { + List content = page.getContent().stream() + .map(mapper) + .toList(); + return new PageResponse<>( + content, + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index c54eff4a1..b7af12426 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -1,12 +1,15 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Brand Admin API", description = "브랜드 관리 API") public interface BrandAdminApiV1Spec { + // Command + @Operation( summary = "브랜드 등록", description = "새로운 입점 브랜드를 등록합니다." @@ -29,4 +32,20 @@ ApiResponse update( description = "브랜드를 삭제합니다." ) ApiResponse delete(Long brandId); + + // Query + + @Operation( + summary = "브랜드 목록 조회", + description = "브랜드 목록을 검색 조건과 함께 페이징 조회합니다." + ) + ApiResponse> list( + BrandAdminV1Dto.ListRequest request + ); + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드 상세 정보를 조회합니다." + ) + ApiResponse detail(Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 03f16c475..3ed0c5409 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -3,9 +3,12 @@ import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -20,6 +23,8 @@ public class BrandAdminV1Controller implements BrandAdminApiV1Spec { private final BrandFacade brandFacade; + // Command + @PostMapping @Override public ApiResponse register( @@ -43,4 +48,23 @@ public ApiResponse delete(@PathVariable Long brandId) { brandFacade.delete(brandId); return ApiResponse.success(); } + + // Query + + @GetMapping + @Override + public ApiResponse> list( + @Valid BrandAdminV1Dto.ListRequest request) { + Page brands = brandFacade.getList(request.name(), request.toDeleted(), request.toPageable()); + PageResponse pageResponse = + PageResponse.from(brands, BrandAdminV1Dto.BrandResponse::from); + return ApiResponse.success(pageResponse); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse detail(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getDetail(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java index 6003f4e06..6f5fda60a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -1,13 +1,20 @@ package com.loopers.interfaces.api.brand; import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; public class BrandAdminV1Dto { + // Command + public record RegisterRequest( @NotBlank(message = "브랜드명은 필수입니다") @Size(max = 100, message = "브랜드명은 100자 이하여야 합니다") @@ -25,13 +32,51 @@ public record UpdateRequest( String description ) {} + // Query + + public record ListRequest( + String name, + BrandStatus status, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListRequest { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public enum BrandStatus { + ACTIVE, DELETED; + + public Boolean toDeleted() { + return this == DELETED ? Boolean.TRUE : Boolean.FALSE; + } + } + + public Boolean toDeleted() { + return status != null ? status.toDeleted() : null; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + + // Response + public record BrandResponse( Long id, String name, String description, String status, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + LocalDateTime deletedAt ) { public static BrandResponse from(BrandInfo info) { return new BrandResponse( @@ -40,7 +85,8 @@ public static BrandResponse from(BrandInfo info) { info.description(), info.status().name(), info.createdAt(), - info.updatedAt() + info.updatedAt(), + info.deletedAt() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 7c8c6019b..2dfe48074 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import static org.assertj.core.api.Assertions.*; @@ -159,4 +161,83 @@ class 브랜드_조회 { assertThat(result.isDeleted()).isTrue(); } } + + @Nested + class 브랜드_목록_조회 { + + @Test + void 조건_없이_조회하면_전체_브랜드가_최신순으로_반환된다() { + brandService.register("나이키", "스포츠 브랜드"); + brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register("뉴발란스", "미국 스포츠 브랜드"); + + Page result = brandService.findBrands(null, null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getName()).isEqualTo("뉴발란스"); + assertThat(result.getContent().get(1).getName()).isEqualTo("아디다스"); + assertThat(result.getContent().get(2).getName()).isEqualTo("나이키"); + } + + @Test + void name_키워드로_검색하면_부분_일치하는_브랜드만_반환된다() { + brandService.register("나이키 에어", "에어 시리즈"); + brandService.register("나이키 조던", "조던 시리즈"); + brandService.register("아디다스", "독일 스포츠 브랜드"); + + Page result = brandService.findBrands("나이키", null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Brand::getName) + .containsExactly("나이키 조던", "나이키 에어"); + } + + @Test + void deleted_false면_활성_브랜드만_반환된다() { + brandService.register("나이키", "스포츠 브랜드"); + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + adidas.delete(); + brandRepository.save(adidas); + + Page result = brandService.findBrands(null, false, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("나이키"); + } + + @Test + void deleted_true면_삭제된_브랜드만_반환된다() { + brandService.register("나이키", "스포츠 브랜드"); + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + adidas.delete(); + brandRepository.save(adidas); + + Page result = brandService.findBrands(null, true, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("아디다스"); + } + + @Test + void 복합_조건_name과_deleted_적용_시_모두_반영된다() { + brandService.register("나이키 에어", "에어 시리즈"); + Brand deletedNike = brandService.register("나이키 조던", "조던 시리즈"); + deletedNike.delete(); + brandRepository.save(deletedNike); + brandService.register("아디다스", "독일 스포츠 브랜드"); + + Page result = brandService.findBrands("나이키", false, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("나이키 에어"); + } + + @Test + void 결과가_없으면_빈_페이지를_반환한다() { + Page result = brandService.findBrands("존재하지않는", null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 4461431e5..3984b0526 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -278,6 +279,229 @@ class 브랜드_삭제 { } } + @Nested + class 브랜드_목록_조회 { + + @Test + void 조건_없이_조회하면_전체_브랜드를_최신_등록순으로_페이징하여_200_응답한다() { + registerBrand("나이키", "스포츠 브랜드"); + registerBrand("아디다스", "독일 스포츠 브랜드"); + registerBrand("뉴발란스", "미국 스포츠 브랜드"); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("뉴발란스"), + () -> assertThat(response.getBody().data().content().get(1).name()).isEqualTo("아디다스"), + () -> assertThat(response.getBody().data().content().get(2).name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().page()).isEqualTo(0), + () -> assertThat(response.getBody().data().size()).isEqualTo(20) + ); + } + + @Test + void 삭제된_브랜드도_포함하여_반환한다() { + registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); + deleteBrand(deletedBrandId); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(BrandAdminV1Dto.BrandResponse::status) + .containsExactly("DELETED", "ACTIVE") + ); + } + + @Test + void name_키워드로_검색하면_해당_키워드가_포함된_브랜드만_반환한다() { + registerBrand("나이키", "스포츠 브랜드"); + registerBrand("아디다스", "독일 스포츠 브랜드"); + registerBrand("뉴발란스", "미국 스포츠 브랜드"); + + ResponseEntity>> response = + getList("?name=나이키"); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") + ); + } + + @Test + void status_ACTIVE로_필터링하면_활성_브랜드만_반환한다() { + registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); + deleteBrand(deletedBrandId); + + ResponseEntity>> response = + getList("?status=ACTIVE"); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("ACTIVE") + ); + } + + @Test + void status_DELETED로_필터링하면_삭제된_브랜드만_반환한다() { + registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); + deleteBrand(deletedBrandId); + + ResponseEntity>> response = + getList("?status=DELETED"); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("아디다스"), + () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("DELETED") + ); + } + + @Test + void status에_유효하지_않은_값을_보내면_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?status=INVALID", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void name_검색과_status_필터를_동시에_적용할_수_있다() { + registerBrand("나이키 에어", "에어 시리즈"); + Long deletedId = registerBrand("나이키 조던", "조던 시리즈"); + deleteBrand(deletedId); + registerBrand("아디다스", "독일 스포츠 브랜드"); + + ResponseEntity>> response = + getList("?name=나이키&status=ACTIVE"); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키 에어") + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + ResponseEntity>> response = + getList("?name=존재하지않는브랜드"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + class 브랜드_상세_조회 { + + @Test + void 활성_브랜드를_조회하면_200_응답과_상세_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + + ResponseEntity> response = getDetail(brandId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @Test + void 삭제된_브랜드도_조회할_수_있으며_status가_DELETED로_표시된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + ResponseEntity> response = getDetail(brandId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().status()).isEqualTo("DELETED"), + () -> assertThat(response.getBody().data().deletedAt()).isNotNull() + ); + } + + @Test + void 미존재_브랜드면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { @@ -286,6 +510,10 @@ private Long registerBrand(String name, String description) { return response.getBody().data().id(); } + private void deleteBrand(Long brandId) { + deleteRequest(brandId); + } + private ResponseEntity> postRegister(BrandAdminV1Dto.RegisterRequest request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, @@ -310,6 +538,22 @@ private ResponseEntity> deleteRequest(Long brandId) { ); } + private ResponseEntity>> getList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getDetail(Long brandId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); From aa8e186cf95b870bcf2d0e6072dd6e481fee3446 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 15:57:30 +0900 Subject: [PATCH 12/68] =?UTF-8?q?chore:=20rules=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B0=B0=EC=B9=98=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/code-ordering.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .claude/rules/conventions/code-ordering.md diff --git a/.claude/rules/conventions/code-ordering.md b/.claude/rules/conventions/code-ordering.md new file mode 100644 index 000000000..db2823565 --- /dev/null +++ b/.claude/rules/conventions/code-ordering.md @@ -0,0 +1,43 @@ +# 코드 배치 순서 + +## 원칙 + +클래스 내 멤버는 **공개 범위 넓은 것 → 좁은 것**, 역할별로 그룹핑한다. +모든 클래스에서 private 메서드는 최하단에 배치한다. + +## 계층별 적용 (Service, Facade, Controller, Repository 등) + +- 구분 주석: `// Command` → `// Query` 순서 +- DTO는 `// Command` → `// Query` → `// Response` + +| 계층 | 순서 | 비고 | +|------|------|------| +| Controller | `// Command` → `// Query` | POST/PATCH/DELETE → GET | +| ApiSpec | `// Command` → `// Query` | Controller와 동일 순서 | +| Facade | `// Command` → `// Query` | | +| Service | `// Command` → `// Query` | | +| Repository (interface) | `// Command` → `// Query` | save → find, exists | +| RepositoryImpl | `// Command` → `// Query` | Repository 인터페이스와 동일 순서 | +| JpaRepository | `// Query` 만 | 상속 메서드 생략, 직접 정의한 메서드만 기재 | +| DTO | `// Command` → `// Query` → `// Response` | Request → Query 파라미터 → Response | + +## Entity 멤버 순서 + +Entity는 Command/Query 구분 대신 역할별 순서를 따른다. + +| 순서 | 역할 | 예시 | +|------|------|------| +| 1 | 상수 | `NAME_MAX_LENGTH` | +| 2 | 필드 | `name`, `description` | +| 3 | 생성자 | protected 기본 생성자 → private 생성자 순 | +| 4 | 정적 팩토리 메서드 | `create()` | +| 5 | 명령 메서드 | `update()`, `softDelete()` | +| 6 | 조회 메서드 | `isDeleted()`, `isOrderable()` | +| 7 | 검증 메서드 (public) | `validateNotDeleted()` | +| 8 | private 메서드 | `validateName()`, `validateDescription()` | + +## 주석 형식 + +- 계층별 구분 주석: `// Command`, `// Query`, `// Response` +- Entity는 구분 주석 불필요 — 순서 자체가 규칙 +- 메서드 그룹 사이에 빈 줄 하나 \ No newline at end of file From 50e84b5dcb071067ef553627e3a900b0749f6e26 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 24 Feb 2026 20:54:59 +0900 Subject: [PATCH 13/68] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Service=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 16 +- .../java/com/loopers/domain/brand/Brand.java | 6 - .../loopers/domain/brand/BrandRepository.java | 2 + .../loopers/domain/brand/BrandService.java | 21 +- .../brand/BrandJpaRepository.java | 9 + .../brand/BrandRepositoryImpl.java | 5 + .../interfaces/api/brand/BrandApiV1Spec.java | 26 +++ .../api/brand/BrandV1Controller.java | 40 ++++ .../interfaces/api/brand/BrandV1Dto.java | 55 +++++ .../brand/BrandServiceIntegrationTest.java | 150 +++++++++++-- .../com/loopers/domain/brand/BrandTest.java | 27 ++- .../api/brand/BrandAdminApiE2ETest.java | 10 + .../interfaces/api/brand/BrandApiE2ETest.java | 205 ++++++++++++++++++ 13 files changed, 539 insertions(+), 33 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 90a2327b0..d6a2e5675 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -25,15 +25,13 @@ public BrandInfo register(String name, String description) { @Transactional public BrandInfo update(Long brandId, String name, String description) { - Brand brand = brandService.getBrand(brandId); - brandService.update(brand, name, description); + Brand brand = brandService.update(brandId, name, description); return BrandInfo.from(brand); } @Transactional public void delete(Long brandId) { - Brand brand = brandService.getBrand(brandId); - brandService.delete(brand); + brandService.delete(brandId); } // Query @@ -47,4 +45,14 @@ public BrandInfo getDetail(Long brandId) { Brand brand = brandService.getBrand(brandId); return BrandInfo.from(brand); } + + public Page getActiveList(String name, Pageable pageable) { + Page brands = brandService.findActiveBrands(name, pageable); + return brands.map(BrandInfo::from); + } + + public BrandInfo getActiveDetail(Long brandId) { + Brand brand = brandService.getActiveBrand(brandId); + return BrandInfo.from(brand); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index e78622d4b..795ae8b1c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -47,12 +47,6 @@ public void update(String name, String description) { } } - @Override - public void delete() { - validateNotDeleted(); - super.delete(); - } - public boolean isDeleted() { return getDeletedAt() != null; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index d33cad72c..b1135a0fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -20,4 +20,6 @@ public interface BrandRepository { boolean existsByNameAndIdNot(String name, Long id); Page findAll(String name, Boolean deleted, Pageable pageable); + + Page findAllActive(String name, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index e6880a8ae..f431d5c01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -28,16 +28,22 @@ public Brand register(String name, String description) { } @Transactional - public void update(Brand brand, String name, String description) { + public Brand update(Long brandId, String name, String description) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); + if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } brand.update(name, description); + return brand; } @Transactional - public void delete(Brand brand) { + public void delete(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); brand.delete(); } @@ -51,4 +57,15 @@ public Brand getBrand(Long brandId) { public Page findBrands(String name, Boolean deleted, Pageable pageable) { return brandRepository.findAll(name, deleted, pageable); } + + public Brand getActiveBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); + brand.validateNotDeleted(); + return brand; + } + + public Page findActiveBrands(String name, Pageable pageable) { + return brandRepository.findAllActive(name, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 25f5aaadb..a56f14992 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -23,4 +23,13 @@ public interface BrandJpaRepository extends JpaRepository { + "WHERE (:name IS NULL OR b.name LIKE %:name%) " + "AND (:deleted IS NULL OR (:deleted = true AND b.deletedAt IS NOT NULL) OR (:deleted = false AND b.deletedAt IS NULL))") Page findAll(@Param("name") String name, @Param("deleted") Boolean deleted, Pageable pageable); + + @Query(value = "SELECT b FROM Brand b " + + "WHERE b.deletedAt IS NULL " + + "AND (:name IS NULL OR b.name LIKE %:name%) " + + "ORDER BY b.name ASC", + countQuery = "SELECT COUNT(b) FROM Brand b " + + "WHERE b.deletedAt IS NULL " + + "AND (:name IS NULL OR b.name LIKE %:name%)") + Page findAllActive(@Param("name") String name, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 1369f9410..a86150212 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -43,4 +43,9 @@ public boolean existsByNameAndIdNot(String name, Long id) { public Page findAll(String name, Boolean deleted, Pageable pageable) { return brandJpaRepository.findAll(name, deleted, pageable); } + + @Override + public Page findAllActive(String name, Pageable pageable) { + return brandJpaRepository.findAllActive(name, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java new file mode 100644 index 000000000..e578b1eff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand API", description = "브랜드 사용자 API") +public interface BrandApiV1Spec { + + // Query + + @Operation( + summary = "브랜드 목록 조회", + description = "활성 브랜드 목록을 이름 오름차순으로 페이징 조회합니다." + ) + ApiResponse> list( + BrandV1Dto.ListRequest request + ); + + @Operation( + summary = "브랜드 상세 조회", + description = "활성 브랜드의 상세 정보를 조회합니다." + ) + ApiResponse detail(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..6af7a0247 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/brands") +@RequiredArgsConstructor +public class BrandV1Controller implements BrandApiV1Spec { + + private final BrandFacade brandFacade; + + // Query + + @GetMapping + @Override + public ApiResponse> list( + @Valid BrandV1Dto.ListRequest request) { + Page brands = brandFacade.getActiveList(request.name(), request.toPageable()); + PageResponse pageResponse = + PageResponse.from(brands, BrandV1Dto.BrandResponse::from); + return ApiResponse.success(pageResponse); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse detail(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getActiveDetail(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..4a2411937 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; + +public class BrandV1Dto { + + // Query + + public record ListRequest( + String name, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListRequest { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + + // Response + + public record BrandResponse( + Long id, + String name, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), + info.name(), + info.description(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 2dfe48074..a3d5e6647 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -75,11 +75,29 @@ class 브랜드_수정 { void 유효한_정보로_수정하면_성공한다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getBrand(brand.getId()); - brandService.update(activeBrand, "아디다스", "독일 스포츠 브랜드"); + Brand result = brandService.update(brand.getId(), "아디다스", "독일 스포츠 브랜드"); - assertThat(activeBrand.getName()).isEqualTo("아디다스"); - assertThat(activeBrand.getDescription()).isEqualTo("독일 스포츠 브랜드"); + assertThat(result.getName()).isEqualTo("아디다스"); + assertThat(result.getDescription()).isEqualTo("독일 스포츠 브랜드"); + } + + @Test + void 미존재_브랜드면_예외() { + assertThatThrownBy(() -> brandService.update(999L, "나이키", null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + + @Test + void 삭제된_브랜드를_수정하면_예외() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(brand.getId()); + + assertThatThrownBy(() -> brandService.update(brand.getId(), "아디다스", null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); } @Test @@ -87,9 +105,7 @@ class 브랜드_수정 { brandService.register("나이키", "스포츠 브랜드"); Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); - Brand activeBrand = brandService.getBrand(adidas.getId()); - - assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) + assertThatThrownBy(() -> brandService.update(adidas.getId(), "나이키", null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -103,9 +119,7 @@ class 브랜드_수정 { Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); - Brand activeBrand = brandService.getBrand(adidas.getId()); - - assertThatThrownBy(() -> brandService.update(activeBrand, "나이키", null)) + assertThatThrownBy(() -> brandService.update(adidas.getId(), "나이키", null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -115,11 +129,10 @@ class 브랜드_수정 { void 자기_자신_이름으로_수정하면_정상_처리된다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getBrand(brand.getId()); - brandService.update(activeBrand, "나이키", "변경된 설명"); + Brand result = brandService.update(brand.getId(), "나이키", "변경된 설명"); - assertThat(activeBrand.getName()).isEqualTo("나이키"); - assertThat(activeBrand.getDescription()).isEqualTo("변경된 설명"); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("변경된 설명"); } } @@ -131,10 +144,27 @@ class 브랜드_삭제 { void 활성_브랜드를_삭제하면_삭제_상태로_변경된다() { Brand brand = brandService.register("나이키", "스포츠 브랜드"); - Brand activeBrand = brandService.getBrand(brand.getId()); - brandService.delete(activeBrand); + brandService.delete(brand.getId()); + + Brand result = brandService.getBrand(brand.getId()); + assertThat(result.isDeleted()).isTrue(); + } + + @Test + void 이미_삭제된_브랜드를_삭제해도_성공한다() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(brand.getId()); + + assertThatCode(() -> brandService.delete(brand.getId())) + .doesNotThrowAnyException(); + } - assertThat(activeBrand.isDeleted()).isTrue(); + @Test + void 미존재_브랜드면_예외() { + assertThatThrownBy(() -> brandService.delete(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); } } @@ -240,4 +270,90 @@ class 브랜드_목록_조회 { assertThat(result.getTotalElements()).isEqualTo(0); } } + + @Nested + class 활성_브랜드_조회 { + + @Test + void 활성_브랜드를_조회하면_성공한다() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + + Brand result = brandService.getActiveBrand(brand.getId()); + + assertThat(result.getId()).isEqualTo(brand.getId()); + assertThat(result.getName()).isEqualTo("나이키"); + } + + @Test + void 삭제된_브랜드를_조회하면_예외() { + Brand brand = brandService.register("나이키", "스포츠 브랜드"); + brand.delete(); + brandRepository.save(brand); + + assertThatThrownBy(() -> brandService.getActiveBrand(brand.getId())) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + + @Test + void 미존재_브랜드를_조회하면_예외() { + assertThatThrownBy(() -> brandService.getActiveBrand(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 브랜드입니다"); + } + } + + @Nested + class 활성_브랜드_목록_조회 { + + @Test + void 활성_브랜드만_이름_오름차순으로_반환된다() { + brandService.register("다나이키", "스포츠 브랜드"); + brandService.register("가아디다스", "독일 스포츠 브랜드"); + brandService.register("나뉴발란스", "미국 스포츠 브랜드"); + + Page result = brandService.findActiveBrands(null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getName()).isEqualTo("가아디다스"); + assertThat(result.getContent().get(1).getName()).isEqualTo("나뉴발란스"); + assertThat(result.getContent().get(2).getName()).isEqualTo("다나이키"); + } + + @Test + void 삭제된_브랜드는_제외된다() { + brandService.register("나이키", "스포츠 브랜드"); + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + adidas.delete(); + brandRepository.save(adidas); + + Page result = brandService.findActiveBrands(null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("나이키"); + } + + @Test + void name_키워드로_검색하면_활성_브랜드_중_부분_일치하는_것만_반환된다() { + brandService.register("나이키 에어", "에어 시리즈"); + brandService.register("나이키 조던", "조던 시리즈"); + brandService.register("아디다스", "독일 스포츠 브랜드"); + + Page result = brandService.findActiveBrands("나이키", PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Brand::getName) + .containsExactly("나이키 에어", "나이키 조던"); + } + + @Test + void 결과가_없으면_빈_페이지를_반환한다() { + Page result = brandService.findActiveBrands("존재하지않는", PageRequest.of(0, 20)); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index 5af7b7441..281b289e5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -1,6 +1,7 @@ package com.loopers.domain.brand; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; @@ -39,6 +40,7 @@ class 생성 { void 브랜드명이_null_또는_빈값이면_예외(String name) { assertThatThrownBy(() -> Brand.create(name, "설명")) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 필수입니다"); } @@ -48,6 +50,7 @@ class 생성 { assertThatThrownBy(() -> Brand.create(longName, "설명")) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); } @@ -65,6 +68,7 @@ class 생성 { assertThatThrownBy(() -> Brand.create("나이키", longDescription)) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); } @@ -110,6 +114,16 @@ class 수정 { assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); } + @Test + void 둘_다_null이면_변경되지_않는다() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + brand.update(null, null); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + @ParameterizedTest @ValueSource(strings = {"", " "}) void name이_빈값이면_예외(String name) { @@ -117,6 +131,7 @@ class 수정 { assertThatThrownBy(() -> brand.update(name, null)) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 필수입니다"); } @@ -127,6 +142,7 @@ class 수정 { assertThatThrownBy(() -> brand.update(longName, null)) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); } @@ -137,6 +153,7 @@ class 수정 { assertThatThrownBy(() -> brand.update(null, longDescription)) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); } } @@ -147,19 +164,20 @@ class 삭제 { @Test void 삭제하면_삭제_상태이다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); + brand.delete(); assertThat(brand.isDeleted()).isTrue(); } @Test - void 삭제된_브랜드를_삭제하면_예외() { + void 이미_삭제된_브랜드를_삭제해도_삭제_상태를_유지한다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); brand.delete(); - assertThatThrownBy(brand::delete) - .isInstanceOf(CoreException.class) - .hasMessageContaining("존재하지 않는 브랜드입니다"); + brand.delete(); + + assertThat(brand.isDeleted()).isTrue(); } @Test @@ -169,6 +187,7 @@ class 삭제 { assertThatThrownBy(() -> brand.update("아디다스", "독일 스포츠 브랜드")) .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 3984b0526..edfe05e72 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -246,6 +246,16 @@ class 브랜드_삭제 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } + @Test + void 이미_삭제된_브랜드를_다시_삭제해도_200_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteRequest(brandId); + + ResponseEntity> response = deleteRequest(brandId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + @Test void 미존재_브랜드면_404_응답() { ResponseEntity> response = deleteRequest(999L); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java new file mode 100644 index 000000000..be90d2cb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -0,0 +1,205 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BrandApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + private static final String VALID_LDAP = "admin-ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 브랜드_목록_조회 { + + @Test + void 조건_없이_조회하면_활성_브랜드만_이름_오름차순으로_페이징하여_200_응답한다() { + registerBrand("다나이키", "스포츠 브랜드"); + registerBrand("가아디다스", "독일 스포츠 브랜드"); + registerBrand("나뉴발란스", "미국 스포츠 브랜드"); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("가아디다스"), + () -> assertThat(response.getBody().data().content().get(1).name()).isEqualTo("나뉴발란스"), + () -> assertThat(response.getBody().data().content().get(2).name()).isEqualTo("다나이키"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().page()).isEqualTo(0), + () -> assertThat(response.getBody().data().size()).isEqualTo(20) + ); + } + + @Test + void 삭제된_브랜드는_반환하지_않는다() { + registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); + deleteBrand(deletedBrandId); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") + ); + } + + @Test + void name_키워드로_검색하면_해당_키워드가_포함된_활성_브랜드만_반환한다() { + registerBrand("나이키", "스포츠 브랜드"); + registerBrand("아디다스", "독일 스포츠 브랜드"); + registerBrand("뉴발란스", "미국 스포츠 브랜드"); + + ResponseEntity>> response = + getList("?name=나이키"); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + ResponseEntity>> response = + getList("?name=존재하지않는브랜드"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class 브랜드_상세_조회 { + + @Test + void 활성_브랜드를_조회하면_200_응답과_브랜드_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + + ResponseEntity> response = getDetail(brandId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull() + ); + } + + @Test + void 삭제된_브랜드를_조회하면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 미존재_브랜드를_조회하면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // --- 헬퍼 메서드 --- + + private Long registerBrand(String name, String description) { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity>> getList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getDetail(Long brandId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } +} From 52691dc288adbf8b4cce5bdd873ffe6052c5db39 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 10:18:23 +0900 Subject: [PATCH 14/68] =?UTF-8?q?chore:=20rules=20=EB=B0=8F=20skills=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84=20-=20=EC=BD=94=EB=94=A9=20=EC=9B=90?= =?UTF-8?q?=EC=B9=99=20=EC=B6=94=EA=B0=80,=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B7=9C=EC=B9=99=20=EA=B0=9C=EC=84=A0,=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/core/coding-principles.md | 93 +++++++++++++++++++ .claude/rules/project/architecture.md | 52 +++++------ .claude/skills/rule-manage/SKILL.md | 30 ++++-- .../rule-manage/references/rules-spec.md | 20 ++++ .claude/skills/test-patterns/SKILL.md | 29 +++++- 5 files changed, 191 insertions(+), 33 deletions(-) create mode 100644 .claude/rules/core/coding-principles.md diff --git a/.claude/rules/core/coding-principles.md b/.claude/rules/core/coding-principles.md new file mode 100644 index 000000000..91d85033c --- /dev/null +++ b/.claude/rules/core/coding-principles.md @@ -0,0 +1,93 @@ +# 코딩 원칙 + +**트레이드오프:** 이 가이드라인은 속도보다 **신중함(caution)**에 무게를 둔다. 사소한 작업에는 스스로 판단하라. + +## §0. 선호하는 언어 + +**한국어(Korean)**로 응답하라. 코드는 예외이다. +커밋 메시지 또한 되도록 한글로 명확하게 기능을 설명할 수 있어야 한다. + +## §1. 코딩 전에 생각하라 + +**추측하지 마라. 혼란을 숨기지 마라. 트레이드오프를 드러내라.** + +구현하기 전에: +**가정(assumptions)**을 명시적으로 밝혀라. 확실하지 않으면 질문하라. +여러 해석이 가능하면 모두 제시하라 — 조용히 하나를 고르지 마라. +더 **단순한 접근법(simpler approach)**이 있다면 말하라. 필요하면 반론을 제기하라. +뭔가 불명확하면 멈춰라. 무엇이 헷갈리는지 짚어라. 질문하라. +**읽기 우선(Read Before Write)**: 수정 대상 코드를 반드시 먼저 읽어라. 읽지 않고 수정하지 마라. +**환각 금지(No Hallucination)**: 존재하지 않는 API, 패키지, 파일 경로, 설정 옵션을 지어내지 마라. 확실하지 않으면 먼저 확인하라. + +## §2. 단순함 우선 (Simplicity First) + +**문제를 해결하는 최소한의 코드. 짐작으로 쓴 코드는 금지.** + +요청받지 않은 기능을 추가하지 마라. +한 번만 쓰이는 코드에 **추상화(abstraction)**를 만들지 마라. +요청되지 않은 "유연성"이나 "설정 가능성"을 넣지 마라. +발생할 수 없는 시나리오에 대한 **에러 핸들링(error handling)**을 하지 마라. +200줄로 썼는데 50줄로 가능하면 다시 작성하라. + +자문하라: "시니어 엔지니어가 이거 **오버엔지니어링(over-engineering)**이라고 할까?" 그렇다면 단순화하라. + +## §3. 외과적 변경 (Surgical Changes) + +**건드려야 할 것만 건드려라. 본인이 남긴 부산물만 정리하라.** + +기존 코드를 수정할 때: +주변 코드, 주석, 포매팅을 "개선"하지 마라. +고장나지 않은 것을 **리팩토링(refactoring)**하지 마라. +다르게 할 수 있더라도 **기존 스타일(existing style)**에 맞춰라. +관련 없는 **데드코드(dead code)**를 발견하면 언급만 하라 — 삭제하지 마라. + +본인의 변경으로 사용되지 않게 된 **import/변수/함수**는 제거하라. + +검증 기준: **변경된 모든 라인은 사용자의 요청에 직접 연결**되어야 한다. + +## §4. 목표 중심 실행 (Goal-Driven Execution) + +**성공 기준을 정의하라. 검증될 때까지 **확장 사고(extended thinking)**로 자가점검하며 반복하라.** + +작업을 **검증 가능한 목표(verifiable goals)**로 변환하라: +"유효성 검사 추가" → "잘못된 입력에 대한 테스트를 작성하고, 통과시켜라" +"버그 수정" → "재현하는 테스트를 작성하고, 통과시켜라" +"X 리팩토링" → "리팩토링 전후로 테스트가 통과하는지 확인하라" + +다단계 작업에는 간략한 계획을 제시하라: +``` +[단계] → 검증: [확인사항] +[단계] → 검증: [확인사항] +[단계] → 검증: [확인사항] +``` + +강한 **성공 기준(success criteria)**이 있으면 자율적으로 루프를 돌 수 있다. 약한 기준("되게 해줘")은 끊임없는 확인이 필요하다. + +## §5. 점진적 실행 (Incremental Execution) + +**대규모 변경을 한 번에 하지 마라. 작은 단위로 나눠서 각 단계마다 검증하라.** + +여러 파일을 동시에 변경하기보다, 한 단위씩 변경하고 **중간 검증(intermediate verification)**을 수행하라. +뭔가 깨졌을 때 원인을 특정할 수 있도록 **변경 범위(blast radius)**를 최소화하라. + +## §6. 실패 대응 (Failure Response) + +**에러가 나면 원인부터 파악하라. 같은 시도를 반복하지 마라.** + +에러 발생 시 증상만 고치지 말고 **근본 원인(root cause)**을 분석하라. +같은 명령을 재시도하기 전에 왜 실패했는지 먼저 이해하라. +방향이 틀렸으면 고치려 하지 말고 과감히 버리고 다시 작성하라. **매몰 비용(sunk cost)**에 집착하지 마라. + +## §7. 피드백 반영 (Self-Correction) + +**사용자가 실수를 지적하면 기록하라.** + +1회 지적 → **MEMORY.md**에 교훈을 기록하라. +2회 이상 반복 → **`~/.claude/rules/corrections.md`**로 승격하고, 사용자에게 알려라. + +기록 포맷: +``` +### [금지 행동 한 줄 요약] +상황: [어떤 맥락에서 발생했는지] +교훈: [앞으로 어떻게 해야 하는지] +``` diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index 9cde6ebfa..b2f24c69d 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -38,9 +38,8 @@ interfaces → application → domain ← infrastructure ### Controller (interfaces) - HTTP 요청/응답 변환만 담당 - Request에서 원시값 추출하여 Facade에 전달 -- try-catch 금지 (글로벌 핸들러가 처리) -- `@Valid`로 Request DTO 입력값 형식 검증 - API별 enum은 Request/Response DTO 내부에 inner enum으로 정의 +- 검증 및 예외 처리 규칙은 `conventions/validation.md` 참고 ### Facade (application) - 여러 도메인 Service 호출 오케스트레이션 @@ -48,36 +47,37 @@ interfaces → application → domain ← infrastructure - Domain Entity → Info DTO 변환 - 다른 도메인의 **Service만** 호출 (Repository 직접 호출 금지) -### Service (domain) — 명령 흐름 -- 자기 도메인의 연산 캡슐화 -- **조회 메서드**: 대상 식별만 수행 - - 쿼리에는 식별 조건만 사용한다 - - 식별 조건: 조건을 제거하면 다른 Entity가 조회되는 것 (PK, FK, 복합키, 유니크 관계 등) - - 예: `findById`, `findByUserIdAndProductId`, `findAllByIdIn` - - 동시성 제어가 필요한 경우 락 조회 허용 (`findByIdForUpdate` 등) - - **상태 조건(deletedAt, status 등)은 쿼리에 포함하지 않는다** — 상태 검증은 Entity 책임 -- **명령 메서드**: Entity를 받아서 도메인 연산 수행 (판단+실행) - - 연산에 필요한 DB 조회(중복 확인, 존재 여부 등)는 Service 내부에서 처리 - - 단, 대상 식별 자체가 연산과 불가분인 경우 Service 내부에서 조회 포함 - (예: unlike — 존재 여부 확인이 곧 연산의 전제조건) -- 자기 도메인의 Repository + Domain Service만 사용 -- **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) -- Facade에 도메인 내부 구조(Optional, 상태값, Entity 컬렉션) 노출 최소화 +### Service (domain) — 비즈니스 유스케이스 -#### 식별 조건 vs 상태 조건 판별 기준 +- **각 비즈니스 메서드가 조회부터 실행까지 완결적으로 소유한다** + - 조회를 private 헬퍼로 공유하지 않는다 + - 각 메서드의 조회 방식이 독립적으로 변경될 수 있다 + (예: 수정은 `findByIdForUpdate`, 삭제는 `findById`) -> **"이 조건을 빼면 다른 Entity가 조회되는가?"** +#### 명령을 위한 조회 -- 빼면 다른 Entity가 나온다 → **식별 조건** → 쿼리에 포함 -- 빼도 같은 Entity인데 더 많이 나온다 → **상태 조건** → Entity가 판단 +- 쿼리에는 **식별 조건만** 사용한다 (PK, FK, 복합키, 유니크 관계) +- **상태 조건(deletedAt, status 등)은 쿼리에 포함하지 않는다** — 상태 검증은 Entity 책임 +- 동시성 제어가 필요한 경우 락 조회 허용 (`findByIdForUpdate` 등) +- 연산에 필요한 DB 조회(중복 확인, 존재 여부 등)는 메서드 내부에서 처리 -### QueryService (domain) — 조회 흐름 +> **식별 조건 vs 상태 조건**: "이 조건을 빼면 다른 Entity가 조회되는가?" +> - 다른 Entity가 나온다 → 식별 조건 → 쿼리에 포함 +> - 같은 Entity인데 더 많이 나온다 → 상태 조건 → Entity가 판단 + +#### 조회를 위한 조회 (QueryService) - 표현을 위한 조회 전용 서비스 -- 상태 필터링, 정렬, 페이징 등 쿼리 조건에 포함 가능 -- 예: `getActiveBrand`, `getActiveProducts`, `findOrdersByStatus` -- 조회가 단순한 도메인은 Service에 조회 메서드로 포함해도 무방 -- 조회가 복잡해지는 시점(정렬, 페이징, 검색 조건, DTO 프로젝션)에서 분리 +- 상태 필터링, 정렬, 페이징 등 **쿼리 조건에 자유롭게 포함 가능** +- 예: `getActiveBrands`, `findProductsByStatus`, `searchByKeyword` +- 조회가 단순한 도메인은 Service에 포함해도 무방, 복잡해지면 분리 + +#### 제약 + +- 자기 도메인의 Repository + Domain Service만 사용 +- **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) +- **private 메서드 금지** +- **자가호출 금지** ### Domain Service (domain) — 필요할 때만 생성 - 단일 Entity로 해결 안 되는 비즈니스 로직 diff --git a/.claude/skills/rule-manage/SKILL.md b/.claude/skills/rule-manage/SKILL.md index 46c745eaa..455808a17 100644 --- a/.claude/skills/rule-manage/SKILL.md +++ b/.claude/skills/rule-manage/SKILL.md @@ -1,11 +1,11 @@ --- name: rule-manage -description: Claude Code rules 파일을 생성, 수정, 검증합니다. 사용자가 "rules 만들어줘", "rule 추가해줘", "rules 정리해줘", "CLAUDE.md 분리해줘", "조건부 규칙 추가해줘"를 요청할 때 사용합니다. CLAUDE.md 직접 편집이나 스킬 관련 작업에는 사용하지 마세요. +description: Claude Code rules 파일을 생성, 수정, 삭제, 검증합니다. 사용자가 "rules 만들어줘", "rule 추가해줘", "rule 삭제해줘", "rules 정리해줘", "CLAUDE.md 분리해줘", "조건부 규칙 추가해줘"를 요청할 때 사용합니다. CLAUDE.md 직접 편집이나 스킬 관련 작업에는 사용하지 마세요. --- # Rule Manage -`.claude/rules/` 디렉토리의 rule 파일을 생성, 수정, 검증합니다. +`.claude/rules/` 디렉토리의 rule 파일을 생성, 수정, 삭제, 검증합니다. 공식 규격은 [references/rules-spec.md](references/rules-spec.md) 참고. ## 워크플로우 @@ -17,18 +17,24 @@ description: Claude Code rules 파일을 생성, 수정, 검증합니다. 사용 - 적용 범위: 글로벌(paths 없음) vs 조건부(paths 있음) - 저장 위치: 프로젝트(`.claude/rules/`) vs 개인(`~/.claude/rules/`) -2. 디렉토리 분류 결정: +2. 기존 규칙 중복 확인: + - `.claude/rules/` 내 기존 파일 목록을 확인 + - 동일/유사 주제의 rule이 이미 있으면 사용자에게 알림 + - 중복 시 선택지 제시: 기존 파일에 병합 vs 새 파일로 분리 + +3. 디렉토리 분류 결정: - `core/` — 프로젝트 무관 행동 규칙 (의사결정, 커뮤니케이션) - `project/` — 프로젝트 고유 정보 (기술 스택, 빌드, 아키텍처) - `conventions/` — 코딩 컨벤션 (네이밍, 에러처리, 테스트) - 기존 디렉토리 구조가 있으면 그 구조를 따름 -3. 파일 작성: +4. 파일 작성: - 파일명: 케밥케이스, 내용을 설명하는 이름 (`error-handling.md`) - 글로벌: 프론트매터 없이 바로 `# 제목` - 조건부: `paths` 프론트매터 + glob 패턴 + - 파일 크기: 100-300줄 목표 (rules-spec.md 참고) -4. 스킬 참조가 필요하면 `## 참고 스킬` 섹션 추가: +5. 스킬 참조가 필요하면 `## 참고 스킬` 섹션 추가: ```markdown ## 참고 스킬 - {언제} → {스킬명} @@ -41,7 +47,7 @@ description: Claude Code rules 파일을 생성, 수정, 검증합니다. 사용 3. 사용자 확인 후 적용 수정 유형: -- **CLAUDE.md 분리**: 큰 CLAUDE.md를 rules/로 주제별 분리 +- **CLAUDE.md 분리**: 큰 CLAUDE.md를 rules/로 주제별 분리 (분리 후 50-100줄 목표, rules-spec.md 참고) - **paths 추가**: 글로벌 규칙을 조건부로 전환 - **규칙 병합**: 비슷한 주제의 파일 통합 - **전역 이동**: 프로젝트 규칙을 `~/.claude/rules/`로 승격 @@ -59,12 +65,24 @@ description: Claude Code rules 파일을 생성, 수정, 검증합니다. 사용 - 파일당 하나의 주제를 다루는가 - 파일명이 내용을 설명하는가 - 하위 디렉토리가 논리적으로 분류되어 있는가 + - 파일 크기가 적절한가 (100-300줄 목표, rules-spec.md 참고) 3. **내용 확인** - 규칙이 구체적인가 ("적절히" 같은 모호한 표현 없는가) - 중복 규칙이 없는가 (다른 rule 파일과 겹치지 않는가) - 참고 스킬이 명시되어 있으면 해당 스킬이 존재하는가 +### 삭제 + +1. 대상 rule 파일을 읽고 현재 내용 확인 +2. 삭제 영향 분석: + - 이 규칙을 참조하는 다른 rule이나 CLAUDE.md가 있는지 확인 + - 다른 rule의 `## 참고 스킬` 등에서 이 규칙을 언급하는지 확인 +3. 사용자에게 삭제 방식 확인: + - **완전 삭제**: 파일 제거 + - **조건부 비활성화**: paths 프론트매터를 존재하지 않는 패턴으로 변경 (임시 비활성화) +4. 삭제 후 관련 참조 정리 (있는 경우) + ## 예시 ### 예시 1: CLAUDE.md 분리 diff --git a/.claude/skills/rule-manage/references/rules-spec.md b/.claude/skills/rule-manage/references/rules-spec.md index b749207d2..2c2ae11a5 100644 --- a/.claude/skills/rule-manage/references/rules-spec.md +++ b/.claude/skills/rule-manage/references/rules-spec.md @@ -86,6 +86,25 @@ paths: - 범위: 모든 프로젝트에 적용 - 우선순위: 프로젝트 rules보다 **낮음** (프로젝트가 오버라이드) +## CLAUDE.md 크기 가이드 + +- **50-100줄** 유지 권장 +- 각 줄마다 자문: "이 줄을 빼면 Claude가 실수할까?" +- 프로젝트 개요, 구조, Rules 구조 안내만 남기고 상세 규칙은 rules/로 위임 +- 너무 길면 Claude가 중요한 규칙을 놓침 + +## 로딩 우선순위 + +아래에서 위로 로드되며, 같은 키가 겹치면 위가 오버라이드: + +| 순서 | 위치 | 범위 | +|------|------|------| +| 1 (최하) | Managed Policy (`/Library/Application Support/ClaudeCode/`) | 조직 IT 배포 | +| 2 | `~/.claude/CLAUDE.md`, `~/.claude/rules/` | 유저 전체 | +| 3 | `./CLAUDE.md`, `./.claude/rules/` | 프로젝트 (팀 공유) | +| 4 | `./CLAUDE.local.md` | 프로젝트 (개인, gitignore) | +| 5 (최상) | `foo/CLAUDE.md` (하위 디렉토리) | 온디맨드 (해당 경로 작업 시만) | + ## 심링크 지원 ```bash @@ -104,3 +123,4 @@ ln -s ~/company-standards/security.md .claude/rules/security.md - **서술적 파일명**: 파일명만으로 내용을 알 수 있게 - **조건부 규칙은 신중히**: paths가 정말 특정 파일 유형에만 해당할 때만 사용 - **하위 디렉토리로 정리**: 관련 규칙을 그룹화 (frontend/, backend/) +- **적절한 파일 크기**: 파일당 100-300줄 목표. 너무 짧으면 맥락 부족, 너무 길면 집중도 저하 diff --git a/.claude/skills/test-patterns/SKILL.md b/.claude/skills/test-patterns/SKILL.md index 223dcc02c..9b280b6e0 100644 --- a/.claude/skills/test-patterns/SKILL.md +++ b/.claude/skills/test-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: test-patterns -description: 테스트 작성 패턴. "테스트 작성해줘", "단위 테스트 만들어줘", "통합 테스트 구현해줘", "E2E 테스트 추가해줘" 요청 시 사용. 단위/통합/E2E 테스트 구조, Mock 원칙, 네이밍 컨벤션 제공. +description: 테스트 작성 패턴. "테스트 작성해줘", "단위 테스트 만들어줘", "통합 테스트 구현해줘", "E2E 테스트 추가해줘" 요청할 때 사용합니다. 단위/통합/E2E 테스트 구조, Mock 원칙, 네이밍 컨벤션 제공. 테스트 실행이나 CI 설정에는 사용하지 마세요. --- # 테스트 패턴 및 컨벤션 @@ -488,6 +488,33 @@ class UserApiE2ETest { --- +## 상세 예제 + +도메인 유형별 구체적인 테스트 코드 예제는 아래 파일을 참고합니다. + +### 단위 테스트 예제 + +| 대상 | 예제 파일 | 주요 내용 | +|------|----------|----------| +| Entity | [examples/entity.md](examples/entity.md) | 생성, 상태 전이, 경계값, Fixture 패턴 | +| VO | [examples/vo.md](examples/vo.md) | 생성 검증, 불변성, 연산, 경계값 | +| Enum | [examples/enum.md](examples/enum.md) | 상태 전이, 그룹화, 매핑, 새 값 추가 방어 | +| Domain Service | [examples/domain-service.md](examples/domain-service.md) | 복합 비즈니스 규칙, 정책 분기, 시간 의존 로직 | + +### 통합 테스트 예제 + +| 대상 | 예제 파일 | 주요 내용 | +|------|----------|----------| +| Service IT | [examples/service-it.md](examples/service-it.md) | 전체 흐름, 트랜잭션, 동시성, Fake 활용 | + +### 테스트 인프라 + +| 대상 | 예제 파일 | 주요 내용 | +|------|----------|----------| +| Fake 구현체 | [examples/fake.md](examples/fake.md) | Fake vs Dummy, 시나리오 제어, 디렉토리 구조 | + +--- + ## 체크리스트 ### 공통 From d755d8ce35f46851d67bc6968d113cf079c8f9b5 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 10:44:17 +0900 Subject: [PATCH 15/68] =?UTF-8?q?chore:=20coding-principles=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=95=AD=EB=AA=A9=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A7=88=ED=81=AC=EB=8B=A4=EC=9A=B4=20=EC=84=9C=EC=8B=9D=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/core/coding-principles.md | 52 ++++++++++++------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/.claude/rules/core/coding-principles.md b/.claude/rules/core/coding-principles.md index 91d85033c..6ffb083a3 100644 --- a/.claude/rules/core/coding-principles.md +++ b/.claude/rules/core/coding-principles.md @@ -4,30 +4,26 @@ ## §0. 선호하는 언어 -**한국어(Korean)**로 응답하라. 코드는 예외이다. -커밋 메시지 또한 되도록 한글로 명확하게 기능을 설명할 수 있어야 한다. +- **한국어(Korean)**로 응답하라. 코드는 예외이다. +- 커밋 메시지 또한 되도록 한글로 명확하게 기능을 설명할 수 있어야 한다. ## §1. 코딩 전에 생각하라 **추측하지 마라. 혼란을 숨기지 마라. 트레이드오프를 드러내라.** 구현하기 전에: -**가정(assumptions)**을 명시적으로 밝혀라. 확실하지 않으면 질문하라. -여러 해석이 가능하면 모두 제시하라 — 조용히 하나를 고르지 마라. -더 **단순한 접근법(simpler approach)**이 있다면 말하라. 필요하면 반론을 제기하라. -뭔가 불명확하면 멈춰라. 무엇이 헷갈리는지 짚어라. 질문하라. -**읽기 우선(Read Before Write)**: 수정 대상 코드를 반드시 먼저 읽어라. 읽지 않고 수정하지 마라. -**환각 금지(No Hallucination)**: 존재하지 않는 API, 패키지, 파일 경로, 설정 옵션을 지어내지 마라. 확실하지 않으면 먼저 확인하라. +- 더 **단순한 접근법(simpler approach)**이 있다면 말하라. 필요하면 반론을 제기하라. +- **읽기 우선(Read Before Write)**: 수정 대상 코드를 반드시 먼저 읽어라. 읽지 않고 수정하지 마라. +- **환각 금지(No Hallucination)**: 존재하지 않는 API, 패키지, 파일 경로, 설정 옵션을 지어내지 마라. 확실하지 않으면 먼저 확인하라. ## §2. 단순함 우선 (Simplicity First) **문제를 해결하는 최소한의 코드. 짐작으로 쓴 코드는 금지.** -요청받지 않은 기능을 추가하지 마라. -한 번만 쓰이는 코드에 **추상화(abstraction)**를 만들지 마라. -요청되지 않은 "유연성"이나 "설정 가능성"을 넣지 마라. -발생할 수 없는 시나리오에 대한 **에러 핸들링(error handling)**을 하지 마라. -200줄로 썼는데 50줄로 가능하면 다시 작성하라. +- 한 번만 쓰이는 코드에 **추상화(abstraction)**를 만들지 마라. +- 요청되지 않은 "유연성"이나 "설정 가능성"을 넣지 마라. +- 발생할 수 없는 시나리오에 대한 **에러 핸들링(error handling)**을 하지 마라. +- 200줄로 썼는데 50줄로 가능하면 다시 작성하라. 자문하라: "시니어 엔지니어가 이거 **오버엔지니어링(over-engineering)**이라고 할까?" 그렇다면 단순화하라. @@ -36,10 +32,10 @@ **건드려야 할 것만 건드려라. 본인이 남긴 부산물만 정리하라.** 기존 코드를 수정할 때: -주변 코드, 주석, 포매팅을 "개선"하지 마라. -고장나지 않은 것을 **리팩토링(refactoring)**하지 마라. -다르게 할 수 있더라도 **기존 스타일(existing style)**에 맞춰라. -관련 없는 **데드코드(dead code)**를 발견하면 언급만 하라 — 삭제하지 마라. +- 주변 코드, 주석, 포매팅을 "개선"하지 마라. +- 고장나지 않은 것을 **리팩토링(refactoring)**하지 마라. +- 다르게 할 수 있더라도 **기존 스타일(existing style)**에 맞춰라. +- 관련 없는 **데드코드(dead code)**를 발견하면 언급만 하라 — 삭제하지 마라. 본인의 변경으로 사용되지 않게 된 **import/변수/함수**는 제거하라. @@ -47,12 +43,12 @@ ## §4. 목표 중심 실행 (Goal-Driven Execution) -**성공 기준을 정의하라. 검증될 때까지 **확장 사고(extended thinking)**로 자가점검하며 반복하라.** +**성공 기준을 정의하라. 검증될 때까지 확장 사고(extended thinking)로 자가점검하며 반복하라.** 작업을 **검증 가능한 목표(verifiable goals)**로 변환하라: -"유효성 검사 추가" → "잘못된 입력에 대한 테스트를 작성하고, 통과시켜라" -"버그 수정" → "재현하는 테스트를 작성하고, 통과시켜라" -"X 리팩토링" → "리팩토링 전후로 테스트가 통과하는지 확인하라" +- "유효성 검사 추가" → "잘못된 입력에 대한 테스트를 작성하고, 통과시켜라" +- "버그 수정" → "재현하는 테스트를 작성하고, 통과시켜라" +- "X 리팩토링" → "리팩토링 전후로 테스트가 통과하는지 확인하라" 다단계 작업에는 간략한 계획을 제시하라: ``` @@ -67,23 +63,23 @@ **대규모 변경을 한 번에 하지 마라. 작은 단위로 나눠서 각 단계마다 검증하라.** -여러 파일을 동시에 변경하기보다, 한 단위씩 변경하고 **중간 검증(intermediate verification)**을 수행하라. -뭔가 깨졌을 때 원인을 특정할 수 있도록 **변경 범위(blast radius)**를 최소화하라. +- 여러 파일을 동시에 변경하기보다, 한 단위씩 변경하고 **중간 검증(intermediate verification)**을 수행하라. +- 뭔가 깨졌을 때 원인을 특정할 수 있도록 **변경 범위(blast radius)**를 최소화하라. ## §6. 실패 대응 (Failure Response) **에러가 나면 원인부터 파악하라. 같은 시도를 반복하지 마라.** -에러 발생 시 증상만 고치지 말고 **근본 원인(root cause)**을 분석하라. -같은 명령을 재시도하기 전에 왜 실패했는지 먼저 이해하라. -방향이 틀렸으면 고치려 하지 말고 과감히 버리고 다시 작성하라. **매몰 비용(sunk cost)**에 집착하지 마라. +- 에러 발생 시 증상만 고치지 말고 **근본 원인(root cause)**을 분석하라. +- 같은 명령을 재시도하기 전에 왜 실패했는지 먼저 이해하라. +- 방향이 틀렸으면 고치려 하지 말고 과감히 버리고 다시 작성하라. **매몰 비용(sunk cost)**에 집착하지 마라. ## §7. 피드백 반영 (Self-Correction) **사용자가 실수를 지적하면 기록하라.** -1회 지적 → **MEMORY.md**에 교훈을 기록하라. -2회 이상 반복 → **`~/.claude/rules/corrections.md`**로 승격하고, 사용자에게 알려라. +- 1회 지적 → **MEMORY.md**에 교훈을 기록하라. +- 2회 이상 반복 → **`~/.claude/rules/corrections.md`**로 승격하고, 사용자에게 알려라. 기록 포맷: ``` From f9946d98592d5643f65376df09d007e32c7eae7d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 11:07:45 +0900 Subject: [PATCH 16/68] =?UTF-8?q?chore:=20rules=20=EC=9A=A9=EC=96=B4=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20-=20Service=EB=A5=BC=20ApplicationService?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/code-ordering.md | 4 ++-- .claude/rules/conventions/testing.md | 6 ++--- .claude/rules/conventions/validation.md | 2 +- .claude/rules/project/architecture.md | 27 ++++++++++++---------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.claude/rules/conventions/code-ordering.md b/.claude/rules/conventions/code-ordering.md index db2823565..386fcca68 100644 --- a/.claude/rules/conventions/code-ordering.md +++ b/.claude/rules/conventions/code-ordering.md @@ -5,7 +5,7 @@ 클래스 내 멤버는 **공개 범위 넓은 것 → 좁은 것**, 역할별로 그룹핑한다. 모든 클래스에서 private 메서드는 최하단에 배치한다. -## 계층별 적용 (Service, Facade, Controller, Repository 등) +## 계층별 적용 (ApplicationService, Facade, Controller, Repository 등) - 구분 주석: `// Command` → `// Query` 순서 - DTO는 `// Command` → `// Query` → `// Response` @@ -15,7 +15,7 @@ | Controller | `// Command` → `// Query` | POST/PATCH/DELETE → GET | | ApiSpec | `// Command` → `// Query` | Controller와 동일 순서 | | Facade | `// Command` → `// Query` | | -| Service | `// Command` → `// Query` | | +| ApplicationService | `// Command` → `// Query` | | | Repository (interface) | `// Command` → `// Query` | save → find, exists | | RepositoryImpl | `// Command` → `// Query` | Repository 인터페이스와 동일 순서 | | JpaRepository | `// Query` 만 | 상속 메서드 생략, 직접 정의한 메서드만 기재 | diff --git a/.claude/rules/conventions/testing.md b/.claude/rules/conventions/testing.md index 32e823ef1..1810cc3cf 100644 --- a/.claude/rules/conventions/testing.md +++ b/.claude/rules/conventions/testing.md @@ -2,7 +2,7 @@ ## 테스트 분류 - 단위 테스트: 도메인 모델 검증 -- 통합 테스트: Service + Repository (TestContainers) +- 통합 테스트: ApplicationService + Repository (TestContainers) - E2E 테스트: API 엔드포인트 전체 흐름 ## 계층별 테스트 전략 @@ -10,8 +10,8 @@ | 계층 | 테스트 유형 | 이유 | |------|-----------|------| | Entity, VO, Domain Service | 단위 테스트 | 순수 객체, Mock 없이 빠른 피드백 | -| Service | 통합 테스트 | Repository를 통한 DB 검증(중복 체크, 존재 확인)이 핵심 | -| Facade | 별도 테스트 없음 | 얇은 오케스트레이션 — Service 통합 + E2E로 커버 | +| ApplicationService | 통합 테스트 | Repository를 통한 DB 검증(중복 체크, 존재 확인)이 핵심 | +| Facade | 별도 테스트 없음 | 얇은 오케스트레이션 — ApplicationService 통합 + E2E로 커버 | | Controller | E2E 테스트 | HTTP 요청/응답, 상태 코드, 인증 전체 흐름 검증 | ## 테스트 유틸리티 diff --git a/.claude/rules/conventions/validation.md b/.claude/rules/conventions/validation.md index 6254afc69..4a8116bba 100644 --- a/.claude/rules/conventions/validation.md +++ b/.claude/rules/conventions/validation.md @@ -6,7 +6,7 @@ |---|---|---| | `@Valid` (DTO) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | | Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | -| Service | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | +| ApplicationService | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | - `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) - Entity가 검증의 최종 방어선 — DTO 검증이 빠져도 Entity에서 반드시 잡아야 함 diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index b2f24c69d..03215d63c 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -4,8 +4,8 @@ ``` com.loopers/ ├── interfaces/ # REST 컨트롤러, Request/Response DTO -├── application/ # Facade (오케스트레이션, 트랜잭션 경계), Info DTO -├── domain/ # Entity, Service, Domain Service, Repository 인터페이스, VO +├── application/ # Facade, ApplicationService(xxxService), Info DTO +├── domain/ # Entity, Domain Service, Repository 인터페이스, VO ├── infrastructure/ # Repository 구현체, 외부 어댑터 └── support/ # 횡단 관심사 (에러, 유틸, 글로벌 핸들러) ``` @@ -16,13 +16,13 @@ interfaces → application → domain ← infrastructure ``` - domain은 infrastructure를 알지 않음 - 외부 의존성은 인터페이스로 추상화 -- **도메인 간 의존 규칙**: Service는 자기 도메인만 접근, 크로스 도메인 협력은 반드시 Facade에서 +- **도메인 간 의존 규칙**: ApplicationService는 자기 도메인만 접근, 크로스 도메인 협력은 반드시 Facade에서 ## 핵심 패턴 - **Rich Domain Model**: 비즈니스 로직과 도메인 불변식은 Entity에 -- **Service**: 자기 도메인의 연산 캡슐화 (Repository 접근 + Entity/Domain Service 위임) -- **Facade**: 여러 도메인 Service 간 오케스트레이션, 트랜잭션 경계 +- **ApplicationService**: 자기 도메인의 유스케이스 캡슐화 (Repository 접근 + Entity/Domain Service 위임) +- **Facade**: 여러 도메인의 ApplicationService 간 오케스트레이션, 트랜잭션 경계 - **Repository 패턴**: 인터페이스는 `domain/`, 구현체는 `infrastructure/` - **DTO**: Java record 사용 (불변 보장) - **Soft Delete**: `deletedAt` 필드 사용 @@ -42,12 +42,14 @@ interfaces → application → domain ← infrastructure - 검증 및 예외 처리 규칙은 `conventions/validation.md` 참고 ### Facade (application) -- 여러 도메인 Service 호출 오케스트레이션 +- 여러 도메인의 ApplicationService 호출 오케스트레이션 - 트랜잭션 경계 (`@Transactional`) - Domain Entity → Info DTO 변환 -- 다른 도메인의 **Service만** 호출 (Repository 직접 호출 금지) +- 다른 도메인의 **ApplicationService만** 호출 (Repository 직접 호출 금지) +- Controller는 **항상 Facade만 호출** (일관성 유지, Entity가 Controller에 노출되지 않음) +- 단일 도메인이어도 Facade 유지 (DTO 변환 책임 + 크로스 도메인 확장 대비) -### Service (domain) — 비즈니스 유스케이스 +### ApplicationService (application) — 유스케이스 - **각 비즈니스 메서드가 조회부터 실행까지 완결적으로 소유한다** - 조회를 private 헬퍼로 공유하지 않는다 @@ -70,14 +72,15 @@ interfaces → application → domain ← infrastructure - 표현을 위한 조회 전용 서비스 - 상태 필터링, 정렬, 페이징 등 **쿼리 조건에 자유롭게 포함 가능** - 예: `getActiveBrands`, `findProductsByStatus`, `searchByKeyword` -- 조회가 단순한 도메인은 Service에 포함해도 무방, 복잡해지면 분리 +- 조회가 단순한 도메인은 ApplicationService에 포함해도 무방, 복잡해지면 분리 #### 제약 - 자기 도메인의 Repository + Domain Service만 사용 -- **다른 도메인의 Service 직접 호출 금지** (크로스 도메인은 Facade 책임) +- **다른 도메인의 ApplicationService 직접 호출 금지** (크로스 도메인은 Facade 책임) - **private 메서드 금지** - **자가호출 금지** +- 네이밍: `xxxService` (예: `BrandService`) ### Domain Service (domain) — 필요할 때만 생성 - 단일 Entity로 해결 안 되는 비즈니스 로직 @@ -96,8 +99,8 @@ interfaces → application → domain ← infrastructure - 구현체는 `infrastructure/` 패키지에 배치 ### 트랜잭션 전략 -- **Service**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 +- **ApplicationService**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 - **Facade**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 - - Facade가 있으면 Service의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) + - Facade가 있으면 ApplicationService의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) From 8f3657c0111a55dd9e6235a88644bef9865b3231 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 11:09:37 +0900 Subject: [PATCH 17/68] =?UTF-8?q?refactor:=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20Example=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 -- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 --- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../api/example/ExampleV1ApiE2ETest.java | 114 ------------------ 13 files changed, 439 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java deleted file mode 100644 index 70f256149..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} From d32f8ea2230c481b50c3f997170e0d2381dab385 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 11:19:53 +0900 Subject: [PATCH 18/68] =?UTF-8?q?refactor:=20UserService=EB=A5=BC=20domain?= =?UTF-8?q?=EC=97=90=EC=84=9C=20application=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/user/UserFacade.java | 1 - .../loopers/{domain => application}/user/UserService.java | 5 ++++- .../com/loopers/interfaces/api/auth/AuthUserResolver.java | 2 +- .../user/UserServiceIntegrationTest.java | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/user/UserService.java (91%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/user/UserServiceIntegrationTest.java (98%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index ae557e024..6be9cf088 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.user; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 4ec5ef301..047e9882c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -1,5 +1,8 @@ -package com.loopers.domain.user; +package com.loopers.application.user; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java index eea76eb3b..30f51e758 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserResolver.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.auth; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; +import com.loopers.application.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index 726fc7d8c..14feb7985 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -1,5 +1,6 @@ -package com.loopers.domain.user; +package com.loopers.application.user; +import com.loopers.domain.user.User; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; From 14ccacca35f1828c7363ce8b66031f81fec713e2 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 11:20:39 +0900 Subject: [PATCH 19/68] =?UTF-8?q?refactor:=20BrandService=EB=A5=BC=20domai?= =?UTF-8?q?n=EC=97=90=EC=84=9C=20application=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/brand/BrandFacade.java | 1 - .../loopers/{domain => application}/brand/BrandService.java | 4 +++- .../brand/BrandServiceIntegrationTest.java | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/brand/BrandService.java (95%) rename apps/commerce-api/src/test/java/com/loopers/{domain => application}/brand/BrandServiceIntegrationTest.java (99%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index d6a2e5675..41827a2c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.brand; import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index f431d5c01..5a2a1e861 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.brand; +package com.loopers.application.brand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index a3d5e6647..16ee6e375 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -1,5 +1,7 @@ -package com.loopers.domain.brand; +package com.loopers.application.brand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; From 26d8a29c73e0cbce0b3ff1cce115a91d3913b379 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 11:42:30 +0900 Subject: [PATCH 20/68] =?UTF-8?q?test:=20Brand=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EA=B0=AD=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/test-patterns/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/test-patterns/SKILL.md b/.claude/skills/test-patterns/SKILL.md index 9b280b6e0..0217a6ceb 100644 --- a/.claude/skills/test-patterns/SKILL.md +++ b/.claude/skills/test-patterns/SKILL.md @@ -537,6 +537,7 @@ class UserApiE2ETest { - [ ] `RANDOM_PORT` 설정 - [ ] `TestRestTemplate` 사용 - [ ] HTTP 상태 코드 검증 +- [ ] 스펙(AC)의 모든 상태 코드 시나리오가 E2E에 1:1 매핑되었는지 확인 --- From ab36d9ab4d910fb409af96620f2a0d90ecacb8c4 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 13:36:09 +0900 Subject: [PATCH 21/68] =?UTF-8?q?feat:=20Product=20=EB=93=B1=EB=A1=9D=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 28 +++ .../application/product/ProductInfo.java | 43 ++++ .../application/product/ProductService.java | 25 +++ .../com/loopers/domain/product/Product.java | 110 ++++++++++ .../domain/product/ProductRepository.java | 12 ++ .../product/ProductJpaRepository.java | 7 + .../product/ProductRepositoryImpl.java | 27 +++ .../api/product/ProductAdminApiV1Spec.java | 19 ++ .../api/product/ProductAdminV1Controller.java | 35 +++ .../api/product/ProductAdminV1Dto.java | 75 +++++++ .../ProductServiceIntegrationTest.java | 48 +++++ .../loopers/domain/product/ProductTest.java | 178 ++++++++++++++++ .../api/product/ProductAdminApiE2ETest.java | 199 ++++++++++++++++++ 13 files changed, 806 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..7fcd55ccd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + // Command + + @Transactional + public ProductInfo register(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + Brand brand = brandService.getActiveBrand(brandId); + Product product = productService.register(brandId, name, price, stockQuantity, description); + return ProductInfo.from(product, brand.getName()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..48f6fafd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,43 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + Status status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { + + public enum Status { + ACTIVE, DELETED + } + + public static ProductInfo from(Product product, String brandName) { + return new ProductInfo( + product.getId(), + product.getBrandId(), + brandName, + product.getName(), + product.getPrice(), + product.getStockQuantity(), + product.getDescription(), + product.getLikeCount(), + product.isDeleted() ? Status.DELETED : Status.ACTIVE, + product.getCreatedAt().toLocalDateTime(), + product.getUpdatedAt().toLocalDateTime(), + product.getDeletedAt() != null ? product.getDeletedAt().toLocalDateTime() : null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..ee4ab843d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,25 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductService { + + private final ProductRepository productRepository; + + // Command + + @Transactional + public Product register(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + Product product = Product.create(brandId, name, price, stockQuantity, description); + return productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..c8b7ce5b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "products") +@Getter +public class Product extends BaseEntity { + + private static final int NAME_MAX_LENGTH = 200; + private static final int DESCRIPTION_MAX_LENGTH = 1000; + private static final BigDecimal PRICE_MAX = new BigDecimal("999999999"); + private static final int STOCK_QUANTITY_MAX = 9_999_999; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(nullable = false, length = NAME_MAX_LENGTH) + private String name; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + @Column(name = "stock_quantity", nullable = false) + private Integer stockQuantity; + + @Column(length = DESCRIPTION_MAX_LENGTH) + private String description; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + protected Product() { + } + + private Product(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + this.brandId = brandId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + this.description = description; + this.likeCount = 0; + } + + public static Product create(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + validateDescription(description); + return new Product(brandId, name, price, stockQuantity, description); + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } + + private static void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다"); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다"); + } + if (name.length() > NAME_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 200자 이하여야 합니다"); + } + } + + private static void validatePrice(BigDecimal price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 필수입니다"); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다"); + } + if (price.compareTo(PRICE_MAX) > 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 999,999,999 이하여야 합니다"); + } + } + + private static void validateStockQuantity(Integer stockQuantity) { + if (stockQuantity == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 필수입니다"); + } + if (stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다"); + } + if (stockQuantity > STOCK_QUANTITY_MAX) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 9,999,999 이하여야 합니다"); + } + } + + private static void validateDescription(String description) { + if (description != null && description.length() > DESCRIPTION_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 설명은 1,000자 이하여야 합니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..9690bfa8b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +public interface ProductRepository { + + // Command + Product save(Product product); + + // Query + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..0375b7ca7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..1eeb7d6ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + // Command + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + // Query + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java new file mode 100644 index 000000000..8be55af7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product Admin API", description = "상품 관리 API") +public interface ProductAdminApiV1Spec { + + // Command + + @Operation( + summary = "상품 등록", + description = "특정 브랜드에 속한 신규 상품을 등록합니다." + ) + ApiResponse register( + ProductAdminV1Dto.RegisterRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..b65c4e528 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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-admin/v1/products") +@RequiredArgsConstructor +public class ProductAdminV1Controller implements ProductAdminApiV1Spec { + + private final ProductFacade productFacade; + + // Command + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody ProductAdminV1Dto.RegisterRequest request) { + ProductInfo info = productFacade.register( + request.brandId(), + request.name(), + request.price(), + request.stockQuantity(), + request.description() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..ba2a49f93 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class ProductAdminV1Dto { + + // Command + + public record RegisterRequest( + @NotNull(message = "브랜드 ID는 필수입니다") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다") + @Size(max = 200, message = "상품명은 200자 이하여야 합니다") + String name, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") + @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") + BigDecimal price, + + @NotNull(message = "재고 수량은 필수입니다") + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") + Integer stockQuantity, + + @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") + String description + ) { + } + + // Response + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stockQuantity(), + info.description(), + info.likeCount(), + info.status().name(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..e6675fdc1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 상품_등록 { + + @Test + void 유효한_정보로_등록하면_상품이_생성된다() { + Product result = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThat(result.getId()).isNotNull(); + assertThat(result.getBrandId()).isEqualTo(1L); + assertThat(result.getName()).isEqualTo("운동화"); + assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(result.getStockQuantity()).isEqualTo(100); + assertThat(result.getDescription()).isEqualTo("편한 운동화"); + assertThat(result.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..1c42a943e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,178 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.*; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_상품이_생성된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName()).isEqualTo("운동화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(product.getStockQuantity()).isEqualTo(100); + assertThat(product.getDescription()).isEqualTo("편한 운동화"); + } + + @Test + void likeCount가_0으로_초기화된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @Test + void 설명이_null이면_null로_생성된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, null); + + assertThat(product.getDescription()).isNull(); + } + + @Test + void 브랜드ID가_null이면_예외() { + assertThatThrownBy(() -> Product.create(null, "운동화", new BigDecimal("50000"), 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("브랜드 ID는 필수입니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 상품명이_null_또는_빈값이면_예외(String name) { + assertThatThrownBy(() -> Product.create(1L, name, new BigDecimal("50000"), 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품명은 필수입니다"); + } + + @Test + void 상품명이_200자를_초과하면_예외() { + String longName = "a".repeat(201); + + assertThatThrownBy(() -> Product.create(1L, longName, new BigDecimal("50000"), 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품명은 200자 이하여야 합니다"); + } + + @Test + void 상품명이_200자이면_성공() { + String maxName = "a".repeat(200); + + assertThatCode(() -> Product.create(1L, maxName, new BigDecimal("50000"), 100, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 가격이_null이면_예외() { + assertThatThrownBy(() -> Product.create(1L, "운동화", null, 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 필수입니다"); + } + + @Test + void 가격이_음수면_예외() { + assertThatThrownBy(() -> Product.create(1L, "운동화", new BigDecimal("-1"), 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 0 이상이어야 합니다"); + } + + @Test + void 가격이_0이면_성공() { + assertThatCode(() -> Product.create(1L, "운동화", BigDecimal.ZERO, 100, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 가격이_최대값을_초과하면_예외() { + BigDecimal overMax = new BigDecimal("1000000000"); + + assertThatThrownBy(() -> Product.create(1L, "운동화", overMax, 100, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 999,999,999 이하여야 합니다"); + } + + @Test + void 가격이_최대값이면_성공() { + BigDecimal maxPrice = new BigDecimal("999999999"); + + assertThatCode(() -> Product.create(1L, "운동화", maxPrice, 100, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 재고수량이_null이면_예외() { + assertThatThrownBy(() -> Product.create(1L, "운동화", new BigDecimal("50000"), null, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고 수량은 필수입니다"); + } + + @Test + void 재고수량이_음수면_예외() { + assertThatThrownBy(() -> Product.create(1L, "운동화", new BigDecimal("50000"), -1, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고 수량은 0 이상이어야 합니다"); + } + + @Test + void 재고수량이_0이면_성공() { + assertThatCode(() -> Product.create(1L, "운동화", new BigDecimal("50000"), 0, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 재고수량이_최대값을_초과하면_예외() { + assertThatThrownBy(() -> Product.create(1L, "운동화", new BigDecimal("50000"), 10_000_000, "설명")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고 수량은 9,999,999 이하여야 합니다"); + } + + @Test + void 재고수량이_최대값이면_성공() { + assertThatCode(() -> Product.create(1L, "운동화", new BigDecimal("50000"), 9_999_999, "설명")) + .doesNotThrowAnyException(); + } + + @Test + void 설명이_1000자를_초과하면_예외() { + String longDescription = "a".repeat(1001); + + assertThatThrownBy(() -> Product.create(1L, "운동화", new BigDecimal("50000"), 100, longDescription)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품 설명은 1,000자 이하여야 합니다"); + } + + @Test + void 설명이_1000자이면_성공() { + String maxDescription = "a".repeat(1000); + + assertThatCode(() -> Product.create(1L, "운동화", new BigDecimal("50000"), 100, maxDescription)) + .doesNotThrowAnyException(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java new file mode 100644 index 000000000..ba1f92ae4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -0,0 +1,199 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductAdminApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String VALID_LDAP = "admin-ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 상품_등록 { + + @Test + void 유효한_정보로_등록하면_상품_정보가_반환된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화" + ); + + ResponseEntity> response = postRegister(request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("운동화"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().description()).isEqualTo("편한 운동화"), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0), + () -> assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull() + ); + } + + @Test + void 미존재_브랜드면_404_응답() { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + 999L, "운동화", new BigDecimal("50000"), 100, "설명" + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 브랜드입니다") + ); + } + + @Test + void 삭제된_브랜드면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, "운동화", new BigDecimal("50000"), 100, "설명" + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, "", new BigDecimal("50000"), 100, "설명" + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + 1L, "운동화", new BigDecimal("50000"), 100, "설명" + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + 1L, "운동화", new BigDecimal("50000"), 100, "설명" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + + // --- 헬퍼 메서드 --- + + private Long registerBrand(String name, String description) { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brandId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity> postRegister( + ProductAdminV1Dto.RegisterRequest request) { + return testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } +} From 82fc60c74d100b86f30553fabe833374775a102c Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 13:39:21 +0900 Subject: [PATCH 22/68] =?UTF-8?q?test:=20Brand=20E2E=20=EC=97=A3=EC=A7=80?= =?UTF-8?q?=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/brand/BrandAdminApiE2ETest.java | 68 +++++++++++++++++++ docs/specs/brand/003-brand-delete.md | 3 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index edfe05e72..d7b08690c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -115,6 +115,20 @@ class 브랜드_등록 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + + @Test + void 삭제된_브랜드와_동일한_이름으로_등록하면_409_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(new BrandAdminV1Dto.RegisterRequest("나이키", "다른 설명"), adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } } @Nested @@ -232,6 +246,49 @@ class 브랜드_수정 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + + @Test + void 삭제된_브랜드와_동일한_이름으로_변경하면_409_응답() { + Long nikeId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(nikeId); + Long adidasId = registerBrand("아디다스", "독일 스포츠 브랜드"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + adidasId, HttpMethod.PATCH, + new HttpEntity<>(new BrandAdminV1Dto.UpdateRequest("나이키", null), adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 자기_자신의_현재_이름과_동일한_이름으로_수정하면_200_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", "변경된 설명"); + + ResponseEntity> response = patchUpdate(brandId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("변경된 설명") + ); + } + + @Test + void 삭제된_브랜드를_수정하면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.PATCH, + new HttpEntity<>(new BrandAdminV1Dto.UpdateRequest("변경이름", null), adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } } @Nested @@ -438,6 +495,17 @@ class 브랜드_목록_조회 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } @Nested diff --git a/docs/specs/brand/003-brand-delete.md b/docs/specs/brand/003-brand-delete.md index 21a9b864c..eb955cf6a 100644 --- a/docs/specs/brand/003-brand-delete.md +++ b/docs/specs/brand/003-brand-delete.md @@ -20,11 +20,12 @@ Admin ## 인수 조건 - [ ] 활성 브랜드를 삭제하면 200 응답한다 - [ ] 삭제된 브랜드는 삭제 상태로 변경된다 +- [ ] 이미 삭제된 브랜드를 다시 삭제해도 200 응답한다 - [ ] 브랜드 삭제 시 해당 브랜드에 속한 모든 활성 상품도 삭제 상태로 변경된다 - [ ] 브랜드가 미존재하면 404 응답, 메시지: "존재하지 않는 브랜드입니다" - [ ] 인증 헤더가 누락되면 401 응답, 메시지: "인증 헤더가 필요합니다" - [ ] 인증에 실패하면 401 응답, 메시지: "인증에 실패했습니다" ## 제약 -- 삭제된 브랜드도 미존재로 처리한다 +- 삭제 연산은 멱등하게 동작한다 — 이미 삭제된 브랜드를 다시 삭제해도 정상 응답한다 - 브랜드 삭제와 하위 상품 삭제는 원자적으로 처리한다 (일부만 성공하면 안 된다) From 7ac3a7683c80dabee732c381246213c05fb24019 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 14:20:49 +0900 Subject: [PATCH 23/68] =?UTF-8?q?feat:=20implement,=20implement-review=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/workflow.md | 12 + .claude/rules/project/documentation.md | 3 +- .claude/skills/implement-review/SKILL.md | 205 ++++++++++ .../implement-review/references/doc-checks.md | 200 ++++++++++ .../references/rule-checks.md | 351 ++++++++++++++++++ .../references/test-checks.md | 238 ++++++++++++ .claude/skills/implement/SKILL.md | 213 +++++++++++ 7 files changed, 1220 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/conventions/workflow.md create mode 100644 .claude/skills/implement-review/SKILL.md create mode 100644 .claude/skills/implement-review/references/doc-checks.md create mode 100644 .claude/skills/implement-review/references/rule-checks.md create mode 100644 .claude/skills/implement-review/references/test-checks.md create mode 100644 .claude/skills/implement/SKILL.md diff --git a/.claude/rules/conventions/workflow.md b/.claude/rules/conventions/workflow.md new file mode 100644 index 000000000..000d26679 --- /dev/null +++ b/.claude/rules/conventions/workflow.md @@ -0,0 +1,12 @@ +# 구현 워크플로우 + +## 원칙 +- 기능 구현 전 해당 기능의 requirements, spec, design이 있으면 반드시 먼저 읽는다 +- spec의 AC와 테스트는 1:1 매핑한다 +- 구현 순서: domain → infrastructure → application → interfaces +- 테스트 순서: 단위 → 통합 → E2E +- 컴파일 + 테스트 통과 후 AC 매핑 보고로 마무리한다 +- 검증과 커밋은 사용자가 수행한다 + +## 참고 스킬 +- spec 기반 기능 구현 → implement diff --git a/.claude/rules/project/documentation.md b/.claude/rules/project/documentation.md index 6c9865a7f..cb44c73a9 100644 --- a/.claude/rules/project/documentation.md +++ b/.claude/rules/project/documentation.md @@ -20,8 +20,7 @@ docs/ requirements → specs → design → 구현 ## 규칙 -- 기능 구현 전 해당 기능의 specs가 있으면 먼저 읽고 AC 기반으로 구현할 것 -- specs의 AC와 테스트는 1:1 매핑 +- 구현 시 행동 규칙은 `conventions/workflow.md` 참고 ## 참고 스킬 - 요구사항 정의서 작성 → requirements-writer diff --git a/.claude/skills/implement-review/SKILL.md b/.claude/skills/implement-review/SKILL.md new file mode 100644 index 000000000..2789caa91 --- /dev/null +++ b/.claude/skills/implement-review/SKILL.md @@ -0,0 +1,205 @@ +--- +name: implement-review +description: implement 스킬로 구현한 코드를 문서(requirements, specs, design)와 규칙(rules) 기준으로 리뷰합니다. "/implement-review product/001", "구현 리뷰해줘", "구현 검증해줘", "spec 대로 구현했는지 확인해줘"를 요청할 때 사용합니다. 코드 구현이나 테스트 작성에는 implement 스킬을 사용하세요. +argument-hint: "{epic}/{NNN-feature-name}" +--- + +# Implement Review + +구현 코드를 문서(requirements, specs, design)와 규칙(rules) 기준으로 3축 검증합니다. + +## 4-Phase 워크플로우 + +``` +LOAD → requirements + spec + design 문서 읽기 + 구현/테스트 코드 수집 +CHECK → 3축 검증 (문서 정합성 → 테스트 품질 → 규칙 준수) +REPORT → 위반 사항 + 수정 제안 보고 +FIX → 사용자 승인 시 수정 (선택) +``` + +--- + +## Phase 0: 인자 확인 + +인자 없이 호출된 경우(`/implement-review`만), spec 경로를 사용자에게 확인한다. + +- `docs/specs/` 하위 디렉토리를 탐색하여 사용 가능한 spec 목록을 보여준다 +- 사용자가 선택하면 해당 spec으로 Phase 1을 시작한다 + +--- + +## Phase 1: LOAD + +검증 대상 파일을 수집합니다. 읽기 전용이므로 사용자 확인 없이 바로 CHECK로 진입합니다. + +### 절차 + +1. 요구사항 정의서가 있으면 읽는다: `docs/requirements/{epic}.md` +2. `docs/specs/{epic}/{NNN-feature-name}.md` 읽기 → AC 추출 +3. `docs/design/{epic}/` 관련 문서 읽기 (class-diagram, erd, sequence — 있는 경우만) +4. 해당 도메인의 구현 코드 전체 읽기 (domain, infra, application, interfaces) +5. 해당 도메인의 테스트 코드 전체 읽기 +6. 검증 기준 상기: `references/` 3개 파일 읽기 + - `references/doc-checks.md` — 문서 정합성 체크리스트 + - `references/test-checks.md` — 테스트 품질 체크리스트 + - `references/rule-checks.md` — 규칙 준수 체크리스트 + +### 산출물 + +검증 대상 파일 목록 (내부 메모, 사용자에게 공유하지 않음) + +--- + +## Phase 2: CHECK + +3축 × 26개 항목을 순서대로 검증합니다. + +### 축 1: 문서 정합성 (D1~D8) + +spec/design 문서와 구현 코드의 일치 여부를 확인합니다. + +| # | 항목 | 확인 내용 | +|---|------|---------| +| D1 | AC-테스트 1:1 매핑 | 각 AC에 대응하는 테스트 존재 여부 | +| D2 | API 경로 일치 | spec 엔드포인트 vs Controller @Mapping | +| D3 | 인증 유형 일치 | spec 인증 vs Controller 인증 어노테이션/헤더 | +| D4 | 요청 필드 일치 | spec 요청 vs Request DTO 필드 + validation | +| D5 | 응답 필드 일치 | spec 응답 vs Response DTO 필드 | +| D6 | 에러 메시지 일치 | spec AC 에러 메시지 vs CoreException 메시지 문자열 | +| D7 | ERD 스키마 일치 | ERD 컬럼 vs Entity @Column (design 있을 때만) | +| D8 | Class Diagram 멤버 | 다이어그램 필드/메서드 vs Entity (design 있을 때만) | + +상세 비교 방법은 [references/doc-checks.md](references/doc-checks.md) 참고. + +### 축 2: 테스트 품질 (T1~T9) + +test-patterns 기준으로 테스트 코드 품질을 확인합니다. + +| # | 항목 | 확인 내용 | +|---|------|---------| +| T1 | @Nested 필수 | 모든 @Test가 @Nested 내부에 위치 | +| T2 | ReplaceUnderscores | @DisplayNameGeneration 존재 | +| T3 | @DisplayName 미사용 | @DisplayName 없음 | +| T4 | 한글 메서드명 | 영어 혼용 없음 | +| T5 | 단위: Mock 미사용 | @Mock, Mockito 없음 | +| T6 | 단위: @SpringBootTest 미사용 | 순수 Java | +| T7 | 통합: DatabaseCleanUp | @AfterEach + truncateAllTables | +| T8 | E2E: RANDOM_PORT | @SpringBootTest(webEnvironment) | +| T9 | E2E: HTTP 상태코드 검증 | response.getStatusCode() assertion | + +상세 패턴은 [references/test-checks.md](references/test-checks.md) 참고. + +### 축 3: 규칙 준수 (R1~R9) + +rules/ 파일 기준으로 아키텍처와 코딩 규칙 준수를 확인합니다. + +| # | 항목 | 확인 내용 | +|---|------|---------| +| R1 | Controller→Facade만 | Service/Repository 직접 주입 없음 | +| R2 | Service private 금지 | ApplicationService에 private 메서드 없음 | +| R3 | DTO는 record | class가 아닌 record | +| R4 | 트랜잭션 전략 | readOnly=true 기본 + 명령 오버라이드 | +| R5 | Command→Query 순서 | // Command, // Query 주석 순서 | +| R6 | Entity 멤버 순서 | 상수→필드→생성자→팩토리→명령→조회→검증→private | +| R7 | CoreException 사용 | Entity 검증에서 CoreException(ErrorType, msg) | +| R8 | Repository 위치 | domain에 인터페이스, infrastructure에 구현체 | +| R9 | Facade Info DTO 변환 | Entity가 Controller에 노출되지 않음 | + +상세 확인 방법은 [references/rule-checks.md](references/rule-checks.md) 참고. + +### 판정 기준 + +각 항목은 다음 중 하나로 판정합니다: + +| 판정 | 의미 | +|------|------| +| PASS | 기준 충족 | +| FAIL | 위반 발견 | +| N/A | 해당 없음 (design 문서 없음, 해당 테스트 유형 없음 등) | + +--- + +## Phase 3: REPORT + +검증 결과를 정리하여 보고합니다. + +### 보고 형식 + +``` +## 리뷰: {기능명} + +### 요약 +| 축 | 통과 | 위반 | 해당없음 | +|----|------|------|---------| +| 문서 정합성 | N | N | N | +| 테스트 품질 | N | N | N | +| 규칙 준수 | N | N | N | + +### 위반 사항 (있을 때만) +#### [{코드}] {항목명} +- **위치**: {파일:라인} +- **내용**: 무엇이 잘못되었는지 +- **수정 제안**: 구체적 코드 제안 + +### AC 매핑 현황 +| # | AC | 테스트 | 상태 | +|---|-----|-------|------| + +위반 사항에 대해 수정을 진행할까요? +``` + +**위반 사항이 없으면**: "모든 항목 통과. 위반 사항 없음." 으로 간결하게 보고한다. + +--- + +## Phase 4: FIX (선택) + +사용자가 "수정해줘"라고 응답할 때만 진입합니다. + +### 절차 + +1. 위반 항목을 하나씩 수정한다 +2. 수정된 항목만 재검증한다 (전체 재검증 아님) +3. 수정 결과를 보고한다 + +### 수정 결과 보고 형식 + +``` +## 수정 완료 + +### 수정된 항목 +| # | 항목 | 수정 내용 | 재검증 | +|---|------|---------|--------| +| {코드} | {항목명} | {무엇을 변경했는지} | PASS | + +### 수정된 파일 +| 파일 | 변경 | +|------|------| +| ... | ... | +``` + +--- + +## 예시 + +사용자: `/implement-review product/001-product-register` + +1. **LOAD**: requirements + spec + design 읽기 → 구현/테스트 코드 수집 → references 읽기 +2. **CHECK**: D1~D8 문서 정합성 → T1~T9 테스트 품질 → R1~R9 규칙 준수 +3. **REPORT**: 요약 표 + 위반 사항 + AC 매핑 현황 +4. **FIX**: (사용자 승인 시) 위반 수정 → 재검증 → 결과 보고 + +## 트러블슈팅 + +### spec 파일이 없는 경우 +- "해당 기능의 명세서가 없습니다. `/spec-writer`로 먼저 명세서를 작성해주세요." + +### 구현 코드가 없는 경우 +- "해당 기능의 구현 코드를 찾을 수 없습니다. `/implement`로 먼저 구현해주세요." + +### requirements 문서가 없는 경우 +- 별도 안내 없이 spec부터 진행한다 + +### design 문서가 없는 경우 +- D7, D8을 N/A로 처리하고 나머지 항목만 검증한다 +- 별도 안내 없이 진행한다 diff --git a/.claude/skills/implement-review/references/doc-checks.md b/.claude/skills/implement-review/references/doc-checks.md new file mode 100644 index 000000000..c1cbbb9fc --- /dev/null +++ b/.claude/skills/implement-review/references/doc-checks.md @@ -0,0 +1,200 @@ +# 축 1: 문서 정합성 체크리스트 (D1~D8) + +spec/design 문서와 구현 코드가 일치하는지 확인합니다. + +--- + +## D1: AC-테스트 1:1 매핑 + +### 비교 방법 +1. spec의 AC 목록을 추출한다 +2. 테스트 코드에서 `@Test` 메서드 목록을 추출한다 +3. 각 AC에 대응하는 테스트가 있는지 확인한다 + +### 위반 패턴 +- AC가 있으나 대응하는 테스트가 없음 +- 테스트가 있으나 대응하는 AC가 없음 (과잉 테스트는 위반 아님, 참고 사항으로 보고) + +### 올바른 패턴 +``` +AC: "이름은 1~50자" → ProductTest#이름이_51자_이상이면_예외() +AC: "이름 중복이면 409 Conflict" → ProductServiceIntegrationTest#이름이_중복이면_예외() +AC: "유효한 정보로 등록하면 200" → ProductAdminApiE2ETest#유효한_정보로_등록하면_상품정보가_반환된다() +``` + +--- + +## D2: API 경로 일치 + +### 비교 방법 +1. spec의 `엔드포인트` 섹션에서 HTTP 메서드 + 경로를 추출한다 +2. Controller의 `@RequestMapping`, `@PostMapping`, `@GetMapping` 등과 비교한다 + +### 위반 패턴 +```java +// spec: POST /admin/v1/products +// 실제 구현: +@PostMapping("/api/v1/products") // 경로 불일치 +``` + +### 올바른 패턴 +```java +// spec: POST /admin/v1/products +@PostMapping("/admin/v1/products") // 일치 +``` + +--- + +## D3: 인증 유형 일치 + +### 비교 방법 +1. spec의 `인증` 필드를 확인한다 (불필요 / User / Admin) +2. Controller 또는 ApiSpec의 인증 관련 어노테이션/파라미터를 확인한다 + - Admin: `@RequestHeader("X-Loopers-Ldap")` 또는 관리자 인증 처리 + - User: `@RequestHeader("X-Loopers-LoginId")` + `@RequestHeader("X-Loopers-LoginPw")` + - 불필요: 인증 관련 어노테이션/파라미터 없음 + +### 위반 패턴 +```java +// spec: 인증 Admin +// 실제: 인증 파라미터 없음 +@PostMapping("/admin/v1/products") +public ResponseEntity<...> register(@RequestBody ...) { ... } +``` + +### 올바른 패턴 +```java +// spec: 인증 Admin +@PostMapping("/admin/v1/products") +public ResponseEntity<...> register( + @RequestHeader("X-Loopers-Ldap") String ldap, + @RequestBody ... +) { ... } +``` + +--- + +## D4: 요청 필드 일치 + +### 비교 방법 +1. spec의 `요청` 테이블에서 필드명, 타입, 필수 여부, 제약을 추출한다 +2. Request DTO의 필드와 비교한다 + - 필드명 일치 (camelCase 변환 고려) + - 타입 일치 (String, Long, Integer, BigDecimal 등) + - 필수 여부 → `@NotNull`, `@NotBlank` 존재 여부 + - 제약 → `@Size`, `@Positive`, `@PositiveOrZero` 등 존재 여부 + +### 위반 패턴 +```java +// spec: name - String - 필수 - 1~50자 +public record RegisterRequest( + String name // @NotBlank, @Size 누락 +) {} +``` + +### 올바른 패턴 +```java +// spec: name - String - 필수 - 1~50자 +public record RegisterRequest( + @NotBlank @Size(min = 1, max = 50) String name +) {} +``` + +--- + +## D5: 응답 필드 일치 + +### 비교 방법 +1. spec의 `응답` 테이블에서 필드명과 타입을 추출한다 +2. Response DTO의 필드와 비교한다 + - 필드명 일치 + - 타입 일치 + - 누락/추가 필드 확인 + +### 위반 패턴 +```java +// spec 응답: id, name, description, price +public record ProductResponse( + Long id, + String name, + String description + // price 누락 +) {} +``` + +### 올바른 패턴 +```java +// spec 응답: id, name, description, price +public record ProductResponse( + Long id, + String name, + String description, + BigDecimal price +) {} +``` + +--- + +## D6: 에러 메시지 일치 + +### 비교 방법 +1. spec AC에서 에러 시나리오의 메시지를 추출한다 + - 형식: "xxx이면 NNN {에러 메시지}" 또는 AC 본문의 에러 메시지 +2. Entity/Service의 `CoreException` 생성 시 전달되는 메시지 문자열과 비교한다 + +### 위반 패턴 +```java +// spec AC: "이름이 중복이면 409 Conflict, '이미 존재하는 브랜드 이름입니다'" +throw new CoreException(ErrorType.CONFLICT, "브랜드 이름 중복"); // 메시지 불일치 +``` + +### 올바른 패턴 +```java +// spec AC: "이름이 중복이면 409 Conflict, '이미 존재하는 브랜드 이름입니다'" +throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다"); // 일치 +``` + +--- + +## D7: ERD 스키마 일치 (design 있을 때만) + +### 비교 방법 +1. `docs/design/{epic}/erd.md`의 Mermaid ERD에서 테이블/컬럼을 추출한다 +2. Entity 클래스의 `@Column`, `@Table` 등과 비교한다 + - 테이블명 일치 + - 컬럼명 일치 (snake_case ↔ camelCase 변환 고려) + - 컬럼 타입 일치 + - nullable/not null 일치 + +### 위반 패턴 +```java +// ERD: description VARCHAR(500) nullable +@Column(nullable = false) // nullable 불일치 +private String description; +``` + +### 올바른 패턴 +```java +// ERD: description VARCHAR(500) nullable +@Column(length = 500) // nullable 기본값 true → 일치 +private String description; +``` + +--- + +## D8: Class Diagram 멤버 (design 있을 때만) + +### 비교 방법 +1. `docs/design/{epic}/class-diagram.md`의 Mermaid 클래스 다이어그램에서 필드/메서드를 추출한다 +2. Entity 클래스의 실제 필드/메서드와 비교한다 + - 다이어그램에 있는데 코드에 없는 멤버 + - 코드에 있는데 다이어그램에 없는 멤버 (참고 사항으로 보고, FAIL은 아님) + +### 위반 패턴 +```java +// Class Diagram: Product { -name, -description, -price, +create(), +update() } +// 실제: update() 메서드 누락 +``` + +### 올바른 패턴 +다이어그램의 모든 필드/메서드가 코드에 존재한다. diff --git a/.claude/skills/implement-review/references/rule-checks.md b/.claude/skills/implement-review/references/rule-checks.md new file mode 100644 index 000000000..d46ae1b05 --- /dev/null +++ b/.claude/skills/implement-review/references/rule-checks.md @@ -0,0 +1,351 @@ +# 축 3: 규칙 준수 체크리스트 (R1~R9) + +rules/ 파일 기준으로 아키텍처와 코딩 규칙 준수를 확인합니다. + +--- + +## R1: Controller→Facade만 + +### 참조 규칙 +`project/architecture.md` — "Controller는 항상 Facade만 호출" + +### 확인 방법 +Controller 클래스의 생성자 주입 또는 `@Autowired` 필드를 확인한다. +- Facade만 주입되어야 한다 +- Service, Repository 직접 주입은 위반 + +### 위반 패턴 +```java +@RestController +public class ProductController { + private final ProductService productService; // Service 직접 주입 +} +``` + +### 올바른 패턴 +```java +@RestController +public class ProductController { + private final ProductFacade productFacade; // Facade만 주입 +} +``` + +--- + +## R2: Service private 금지 + +### 참조 규칙 +`project/architecture.md` — "private 메서드 금지, 자가호출 금지" + +### 확인 방법 +ApplicationService 클래스(`xxxService`)에 `private` 접근제어자 메서드가 없는지 확인한다. + +### 위반 패턴 +```java +@Service +public class ProductService { + public Product register(...) { + validateDuplicate(name); // private 헬퍼 호출 + ... + } + + private void validateDuplicate(String name) { ... } // 위반 +} +``` + +### 올바른 패턴 +```java +@Service +public class ProductService { + @Transactional + public Product register(...) { + // 조회부터 실행까지 메서드 내에서 완결 + if (productRepository.existsByName(name)) { + throw new CoreException(ErrorType.CONFLICT, "..."); + } + ... + } +} +``` + +--- + +## R3: DTO는 record + +### 참조 규칙 +`project/architecture.md` — "DTO: Java record 사용 (불변 보장)" + +### 확인 방법 +Request, Response, Info DTO 파일에서 `class` 대신 `record`로 선언되었는지 확인한다. + +### 대상 파일 +- `interfaces/` 패키지의 `*Dto.java` 내부 inner type +- `application/` 패키지의 `*Info.java` + +### 위반 패턴 +```java +public class ProductV1Dto { + public static class RegisterRequest { // class 사용 + private String name; + // getter, setter... + } +} +``` + +### 올바른 패턴 +```java +public class ProductV1Dto { + public record RegisterRequest( // record 사용 + @NotBlank @Size(min = 1, max = 50) String name + ) {} +} +``` + +--- + +## R4: 트랜잭션 전략 + +### 참조 규칙 +`project/architecture.md` — 트랜잭션 전략 + +### 확인 방법 + +**ApplicationService**: +1. 클래스 레벨에 `@Transactional(readOnly = true)`가 있는지 확인한다 +2. 명령 메서드(등록, 수정, 삭제)에 `@Transactional`(readOnly 없음)이 있는지 확인한다 + +**Facade**: +1. 클래스 레벨에 `@Transactional(readOnly = true)`가 있는지 확인한다 +2. 명령 메서드에 `@Transactional`(readOnly 없음)이 있는지 확인한다 + +### 위반 패턴 +```java +@Service +public class ProductService { // 클래스 레벨 @Transactional 누락 + + @Transactional + public Product register(...) { ... } + + public Product getById(Long id) { ... } // readOnly 없음 +} +``` + +### 올바른 패턴 +```java +@Service +@Transactional(readOnly = true) // 클래스 레벨 기본 +public class ProductService { + + @Transactional // 명령 메서드 오버라이드 + public Product register(...) { ... } + + // 조회 메서드는 클래스 레벨 readOnly 상속 + public Product getById(Long id) { ... } +} +``` + +--- + +## R5: Command→Query 순서 + +### 참조 규칙 +`conventions/code-ordering.md` — 계층별 적용 + +### 확인 방법 +다음 파일에서 `// Command`와 `// Query` 주석이 있고, Command가 Query보다 먼저 나오는지 확인한다: +- Controller +- ApiSpec (인터페이스) +- Facade +- ApplicationService +- Repository (인터페이스) +- RepositoryImpl +- DTO 클래스 (`// Command` → `// Query` → `// Response`) + +### 위반 패턴 +```java +public class ProductController { + // Query + @GetMapping("/{id}") + public ResponseEntity<...> getById(...) { ... } + + // Command + @PostMapping + public ResponseEntity<...> register(...) { ... } // Query 뒤에 위치 +} +``` + +### 올바른 패턴 +```java +public class ProductController { + // Command + @PostMapping + public ResponseEntity<...> register(...) { ... } + + // Query + @GetMapping("/{id}") + public ResponseEntity<...> getById(...) { ... } +} +``` + +--- + +## R6: Entity 멤버 순서 + +### 참조 규칙 +`conventions/code-ordering.md` — Entity 멤버 순서 + +### 확인 방법 +Entity 클래스의 멤버가 다음 순서를 지키는지 확인한다: + +1. 상수 (`static final`) +2. 필드 (`@Column` 등) +3. 생성자 (protected 기본 → private) +4. 정적 팩토리 메서드 (`create()`) +5. 명령 메서드 (`update()`, `softDelete()`) +6. 조회 메서드 (`isDeleted()`, `isOrderable()`) +7. 검증 메서드 (public) (`validateNotDeleted()`) +8. private 메서드 (`validateName()`) + +### 위반 패턴 +```java +@Entity +public class Product { + private String name; + + public static Product create(...) { ... } // 팩토리가 생성자보다 먼저 + + protected Product() {} // 생성자가 팩토리 뒤에 + + private void validateName() { ... } // private이 명령보다 먼저 + + public void update(...) { ... } +} +``` + +### 올바른 패턴 +```java +@Entity +public class Product { + // 필드 + private String name; + + // 생성자 + protected Product() {} + private Product(String name) { ... } + + // 정적 팩토리 + public static Product create(...) { ... } + + // 명령 + public void update(...) { ... } + + // private + private void validateName() { ... } +} +``` + +--- + +## R7: CoreException 사용 + +### 참조 규칙 +`conventions/validation.md` — "비즈니스 예외는 CoreException으로 통일" + +### 확인 방법 +Entity와 ApplicationService에서 비즈니스 검증 실패 시 `CoreException(ErrorType, message)` 형태로 예외를 던지는지 확인한다. + +### 위반 패턴 +```java +// Entity에서 일반 예외 사용 +if (name.length() > NAME_MAX_LENGTH) { + throw new IllegalArgumentException("이름은 50자 이하여야 합니다"); +} +``` + +### 올바른 패턴 +```java +// CoreException 사용 +if (name.length() > NAME_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 50자 이하여야 합니다"); +} +``` + +### 예외 +- Entity 생성자에서의 null 체크 등 프레임워크 수준 검증은 `IllegalArgumentException` 허용 가능 +- 이 경우에도 `CoreException`이 선호됨 + +--- + +## R8: Repository 위치 + +### 참조 규칙 +`project/architecture.md` — "인터페이스는 domain/에, 구현체는 infrastructure/에" + +### 확인 방법 +1. Repository 인터페이스가 `domain/` 패키지에 위치하는지 확인한다 +2. Repository 구현체(`RepositoryImpl`)가 `infrastructure/` 패키지에 위치하는지 확인한다 +3. JpaRepository가 `infrastructure/` 패키지에 위치하는지 확인한다 + +### 위반 패턴 +``` +infrastructure/ +├── ProductRepository.java # 인터페이스가 infrastructure에 +├── ProductRepositoryImpl.java +└── ProductJpaRepository.java +``` + +### 올바른 패턴 +``` +domain/ +└── ProductRepository.java # 인터페이스는 domain에 + +infrastructure/ +├── ProductRepositoryImpl.java # 구현체는 infrastructure에 +└── ProductJpaRepository.java +``` + +--- + +## R9: Facade Info DTO 변환 + +### 참조 규칙 +`project/architecture.md` — "Entity → Info DTO 변환 담당", "Entity가 Controller에 노출되지 않음" + +### 확인 방법 +1. Facade가 Entity를 직접 반환하지 않고 Info DTO로 변환하는지 확인한다 +2. Controller에서 Entity 타입을 직접 참조하지 않는지 확인한다 +3. Info DTO가 `application/` 패키지에 위치하는지 확인한다 + +### 위반 패턴 +```java +// Facade에서 Entity 직접 반환 +public class ProductFacade { + public Product register(...) { + return productService.register(...); // Entity 직접 반환 + } +} + +// Controller에서 Entity 참조 +public class ProductController { + public ResponseEntity register(...) { ... } // Entity 노출 +} +``` + +### 올바른 패턴 +```java +// Facade에서 Info DTO 변환 +public class ProductFacade { + public ProductInfo register(...) { + Product product = productService.register(...); + return ProductInfo.from(product); // Info DTO로 변환 + } +} + +// Controller에서 Info → Response 변환 +public class ProductController { + public ResponseEntity register(...) { + ProductInfo info = productFacade.register(...); + return ResponseEntity.ok(ProductResponse.from(info)); + } +} +``` diff --git a/.claude/skills/implement-review/references/test-checks.md b/.claude/skills/implement-review/references/test-checks.md new file mode 100644 index 000000000..a2e7de1cb --- /dev/null +++ b/.claude/skills/implement-review/references/test-checks.md @@ -0,0 +1,238 @@ +# 축 2: 테스트 품질 체크리스트 (T1~T9) + +test-patterns 스킬 기준으로 테스트 코드 품질을 확인합니다. + +참고: `.claude/skills/test-patterns/SKILL.md` + +--- + +## T1: @Nested 필수 + +### 확인 방법 +모든 `@Test` 메서드가 `@Nested` 클래스 내부에 위치하는지 확인한다. + +### 위반 패턴 +```java +class ProductTest { + @Test + void 유효한_값이면_생성된다() { ... } // @Nested 없이 직접 배치 +} +``` + +### 올바른 패턴 +```java +class ProductTest { + @Nested + class 생성 { + @Test + void 유효한_값이면_생성된다() { ... } + } +} +``` + +--- + +## T2: ReplaceUnderscores + +### 확인 방법 +테스트 클래스(최상위)에 `@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)` 어노테이션이 존재하는지 확인한다. + +### 위반 패턴 +```java +class ProductTest { // @DisplayNameGeneration 누락 + @Nested + class 생성 { ... } +} +``` + +### 올바른 패턴 +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductTest { + @Nested + class 생성 { ... } +} +``` + +--- + +## T3: @DisplayName 미사용 + +### 확인 방법 +테스트 파일에 `@DisplayName` 어노테이션이 없는지 확인한다. (`@DisplayNameGeneration`은 허용) + +### 위반 패턴 +```java +@Test +@DisplayName("유효한 값이면 생성된다") +void createProduct() { ... } +``` + +### 올바른 패턴 +```java +@Test +void 유효한_값이면_생성된다() { ... } +``` + +--- + +## T4: 한글 메서드명 + +### 확인 방법 +`@Test` 메서드명과 `@Nested` 클래스명에 영어가 혼용되지 않는지 확인한다. + +### 검출 키워드 +영어 단어가 포함된 메서드명/클래스명 (예: `create_시`, `CONFIRMED`, `confirm_호출시`) + +### 위반 패턴 +```java +@Test +void confirm_호출시_상태가_CONFIRMED로_변경된다() { ... } +``` + +### 올바른 패턴 +```java +@Test +void 확정하면_상태가_확정됨으로_변경된다() { ... } +``` + +### 예외 +- 기술 용어 (API, HTTP, URL 등)는 허용 +- 고유명사 (MySQL, Redis 등)는 허용 + +--- + +## T5: 단위 테스트 — Mock 미사용 + +### 대상 파일 +`{Domain}Test.java` (단위 테스트만 해당) + +### 확인 방법 +다음 키워드가 없는지 확인한다: +- `@Mock`, `@InjectMocks`, `@MockBean` +- `Mockito.`, `mock(`, `when(`, `verify(` +- `import org.mockito` + +### 위반 패턴 +```java +class ProductTest { + @Mock + private ProductRepository productRepository; +} +``` + +### 올바른 패턴 +```java +class ProductTest { + // Mock 없이 순수 객체 테스트 + // 외부 의존성은 Fake 구현체 사용 +} +``` + +--- + +## T6: 단위 테스트 — @SpringBootTest 미사용 + +### 대상 파일 +`{Domain}Test.java` (단위 테스트만 해당) + +### 확인 방법 +`@SpringBootTest` 어노테이션이 없는지 확인한다. + +### 위반 패턴 +```java +@SpringBootTest +class ProductTest { ... } +``` + +### 올바른 패턴 +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductTest { ... } +``` + +--- + +## T7: 통합 테스트 — DatabaseCleanUp + +### 대상 파일 +`{Domain}ServiceIntegrationTest.java` (통합 테스트만 해당) + +### 확인 방법 +1. `DatabaseCleanUp` 필드가 `@Autowired`로 주입되는지 확인한다 +2. `@AfterEach` 메서드에서 `databaseCleanUp.truncateAllTables()` 호출이 있는지 확인한다 + +### 위반 패턴 +```java +@SpringBootTest +class ProductServiceIntegrationTest { + // DatabaseCleanUp 없음 → 테스트 간 데이터 오염 위험 +} +``` + +### 올바른 패턴 +```java +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } +} +``` + +--- + +## T8: E2E 테스트 — RANDOM_PORT + +### 대상 파일 +`{Domain}ApiE2ETest.java` (E2E 테스트만 해당) + +### 확인 방법 +`@SpringBootTest` 어노테이션에 `webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT`가 설정되어 있는지 확인한다. + +### 위반 패턴 +```java +@SpringBootTest // webEnvironment 누락 +class ProductAdminApiE2ETest { ... } +``` + +### 올바른 패턴 +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductAdminApiE2ETest { ... } +``` + +--- + +## T9: E2E 테스트 — HTTP 상태코드 검증 + +### 대상 파일 +`{Domain}ApiE2ETest.java` (E2E 테스트만 해당) + +### 확인 방법 +각 `@Test` 메서드에서 `response.getStatusCode()` 또는 이에 준하는 HTTP 상태코드 assertion이 있는지 확인한다. + +### 위반 패턴 +```java +@Test +void 유효한_정보로_등록하면_상품정보가_반환된다() { + ResponseEntity<...> response = ...; + // 상태코드 검증 없이 바디만 확인 + assertThat(response.getBody().data().name()).isEqualTo("상품A"); +} +``` + +### 올바른 패턴 +```java +@Test +void 유효한_정보로_등록하면_상품정보가_반환된다() { + ResponseEntity<...> response = ...; + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().name()).isEqualTo("상품A"); +} +``` diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 000000000..35f40a109 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,213 @@ +--- +name: implement +description: 기능 명세서(spec) 기반으로 계층별 코드를 구현하고 테스트를 작성합니다. "/implement product/001", "spec 보고 구현해줘", "기능 구현해줘"를 요청할 때 사용합니다. 명세서 작성은 spec-writer, 설계 문서는 design-writer를 사용하세요. +argument-hint: "{epic}/{NNN-feature-name}" +--- + +# Implement + +기능 명세서(spec)를 읽고, 프로젝트 규칙을 준수하며, 계층별 코드를 구현하고 테스트를 작성합니다. + +## 6-Phase 워크플로우 + +``` +READ → spec 읽기 + AC 추출 + rules 상기 +SCAN → 기존 도메인 패턴 파악 + 변경 범위 공유 +BUILD → domain → infra → application → interfaces 순서대로 구현 +TEST → 단위 → 통합 → E2E 작성 +VERIFY → 컴파일 + 테스트 실행 (실패 시 수정 루프) +REPORT → AC 매핑 표 + 파일 목록 보고 +``` + +--- + +## Phase 0: 인자 확인 + +인자 없이 호출된 경우(`/implement`만), spec 경로를 사용자에게 확인한다. + +- `docs/specs/` 하위 디렉토리를 탐색하여 사용 가능한 spec 목록을 보여준다 +- 사용자가 선택하면 해당 spec으로 Phase 1을 시작한다 + +--- + +## Phase 1: READ + +명세서를 읽고 AC를 추출합니다. + +### 절차 + +1. 요구사항 정의서가 있으면 읽는다: `docs/requirements/{epic}.md` +2. `docs/specs/{epic}/{NNN-feature-name}.md` 파일을 읽는다 +3. 설계 문서가 있으면 함께 읽는다: `docs/design/{epic}/` +4. AC를 추출하고 각 AC의 테스트 유형을 분류한다 + +### AC → 테스트 유형 매핑 기준 + +| AC 성격 | 테스트 유형 | 예시 | +|---------|-----------|------| +| Entity 불변식, 검증 규칙 | 단위 테스트 | "이름은 1~50자" | +| DB 조회 필요 (중복, 존재 확인) | 통합 테스트 | "이름 중복이면 409" | +| HTTP 요청/응답 전체 흐름 | E2E 테스트 | "유효한 정보로 등록하면 200" | + +### 사용자에게 공유 + +``` +## READ 결과 + +**Spec**: {파일 경로} + +### AC 분석 +| # | AC | 테스트 유형 | +|---|-----|-----------| +| 1 | ... | 단위 | +| 2 | ... | 통합 | +| 3 | ... | E2E | + +이대로 진행할까요? +``` + +**중요**: 사용자 확인 후 다음 단계로 진행한다. + +--- + +## Phase 2: SCAN + +기존 코드 패턴을 파악하고 변경 범위를 도출합니다. + +### 절차 + +1. 같은 도메인의 기존 코드가 있으면 패턴 파악 (Entity, Repository, Service, Controller) +2. 유사한 다른 도메인의 코드를 참고하여 패턴 확인 + - 참고할 기존 도메인이 없으면(프로젝트 첫 도메인), rules 파일의 계층별 역할과 코드 배치 순서를 기준으로 구현한다 +3. 생성/수정할 파일 목록과 변경 범위를 사용자에게 공유 + +### 규칙 상기 + +이 단계에서 `.claude/rules/` 하위 규칙 파일들을 읽고 코드 배치 순서, 검증 위치, 계층별 역할을 상기한다. + +### 사용자에게 공유 + +``` +## SCAN 결과 + +### 참고 패턴: {참고 도메인} + +### 변경 범위 +| 파일 | 작업 | 비고 | +|------|------|------| +| domain/{Domain}.java | 생성 | Entity | +| domain/{Domain}Repository.java | 생성 | Repository 인터페이스 | +| ... | ... | ... | +``` + +--- + +## Phase 3: BUILD + +계층 순서대로 구현합니다. + +### 구현 순서 + +``` +1. domain → Entity, Repository 인터페이스 +2. infra → RepositoryImpl, JpaRepository +3. application → Service, Facade, Info DTO +4. interfaces → Controller, ApiSpec, Request/Response DTO +``` + +### 점진적 실행 + +- 한 계층씩 구현하고 다음 계층으로 넘어간다 +- 계층 간 의존성이 명확하므로 순서를 지킨다 + +--- + +## Phase 4: TEST + +`test-patterns` 스킬의 패턴을 따라 테스트를 작성합니다. + +### 절차 + +1. `test-patterns` 스킬을 읽고 패턴을 확인한다 + - 스킬을 찾을 수 없으면 `.claude/rules/` 하위 테스트 관련 규칙과 Phase 4의 필수 준수 사항을 기준으로 작성한다 +2. Phase 1에서 분류한 AC별 테스트 유형에 따라 작성한다 + +### 작성 순서 + +``` +1. 단위 테스트 → Entity 불변식, 검증 규칙 +2. 통합 테스트 → Service + DB (중복 체크, 존재 확인) +3. E2E 테스트 → HTTP 전체 흐름 +``` + +--- + +## Phase 5: VERIFY + +컴파일과 테스트를 실행하고, 실패 시 수정합니다. + +### 절차 + +1. 컴파일: `./gradlew :apps:commerce-api:compileJava` +2. 테스트: `./gradlew :apps:commerce-api:test` +3. 실패 시 → 원인 분석 → 수정 → 재실행 (최대 3회) +4. 3회 초과 실패 시 사용자에게 보고하고 방향 확인 + +--- + +## Phase 6: REPORT + +구현 결과를 정리하여 보고합니다. + +### 보고 형식 + +``` +## 구현 완료: {기능명} + +### AC 매핑 +| # | AC | 테스트 | 결과 | +|---|-----|-------|------| +| 1 | ... | {테스트클래스}#메서드명 | ✅ | +| 2 | ... | {테스트클래스}#메서드명 | ✅ | + +### 생성/수정 파일 +| 파일 | 작업 | +|------|------| +| ... | 생성 | +| ... | 수정 | + +### 테스트 결과 +- 단위: N개 통과 +- 통합: N개 통과 +- E2E: N개 통과 + +검증과 커밋은 확인 후 진행해주세요. +구현이 문서/규칙과 일치하는지 확인하려면 `/implement-review`를 실행하세요. +``` + +--- + +## 예시 + +사용자: `/implement product/001-product-register` + +1. **READ**: requirements + spec + design 읽기 → AC 추출 → 테스트 유형 분류 → 사용자 확인 +2. **SCAN**: Brand 도메인 패턴 참고 → 생성 파일 목록 공유 +3. **BUILD**: Product Entity → ProductRepository → ProductService → ProductFacade → ProductController 순서로 구현 +4. **TEST**: ProductTest(단위) → ProductServiceIntegrationTest(통합) → ProductAdminApiE2ETest(E2E) +5. **VERIFY**: 컴파일 + 테스트 실행 → 실패 시 수정 +6. **REPORT**: AC 매핑 표 + 파일 목록 + 테스트 결과 + +## 트러블슈팅 + +### spec 파일이 없는 경우 +- 사용자에게 알리고 `spec-writer` 스킬 사용을 안내한다 +- "해당 기능의 명세서가 없습니다. `/spec-writer`로 먼저 명세서를 작성할까요?" + +### 기존 코드와 충돌하는 경우 +- 기존 코드의 패턴을 우선한다 +- 차이점을 사용자에게 공유하고 방향을 확인받는다 + +### AC가 테스트로 변환하기 어려운 경우 +- 해당 AC를 사용자에게 공유하고 구체화를 요청한다 +- "이 AC는 테스트로 변환하기 어렵습니다: {AC 내용}. 구체적인 검증 기준을 알려주세요." From 54752006927685c31f1c3e84036bee2d4a46d12d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 15:04:39 +0900 Subject: [PATCH 24/68] =?UTF-8?q?feat:=20Product=20=EC=88=98=EC=A0=95=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 7 + .../application/product/ProductService.java | 11 ++ .../com/loopers/domain/product/Product.java | 26 +++ .../api/product/ProductAdminApiV1Spec.java | 9 + .../api/product/ProductAdminV1Controller.java | 17 ++ .../api/product/ProductAdminV1Dto.java | 17 ++ .../ProductServiceIntegrationTest.java | 43 ++++ .../loopers/domain/product/ProductTest.java | 162 +++++++++++++++- .../api/product/ProductAdminApiE2ETest.java | 183 ++++++++++++++++++ docs/design/product/class-diagram.md | 1 + docs/specs/product/002-product-update.md | 1 + 11 files changed, 476 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 7fcd55ccd..e09d7a9f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -25,4 +25,11 @@ public ProductInfo register(Long brandId, String name, BigDecimal price, Integer Product product = productService.register(brandId, name, price, stockQuantity, description); return ProductInfo.from(product, brand.getName()); } + + @Transactional + public ProductInfo update(Long productId, String name, BigDecimal price, Integer stockQuantity, String description) { + Product product = productService.update(productId, name, price, stockQuantity, description); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand.getName()); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index ee4ab843d..17167b709 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -2,6 +2,8 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,4 +24,13 @@ public Product register(Long brandId, String name, BigDecimal price, Integer sto Product product = Product.create(brandId, name, price, stockQuantity, description); return productRepository.save(product); } + + @Transactional + public Product update(Long productId, String name, BigDecimal price, Integer stockQuantity, String description) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + + product.update(name, price, stockQuantity, description); + return product; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index c8b7ce5b8..85cf5977d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -59,10 +59,36 @@ public static Product create(Long brandId, String name, BigDecimal price, Intege return new Product(brandId, name, price, stockQuantity, description); } + public void update(String name, BigDecimal price, Integer stockQuantity, String description) { + validateNotDeleted(); + if (name != null) { + validateName(name); + this.name = name; + } + if (price != null) { + validatePrice(price); + this.price = price; + } + if (stockQuantity != null) { + validateStockQuantity(stockQuantity); + this.stockQuantity = stockQuantity; + } + if (description != null) { + validateDescription(description); + this.description = description; + } + } + public boolean isDeleted() { return getDeletedAt() != null; } + public void validateNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다"); + } + } + private static void validateBrandId(Long brandId) { if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다"); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index 8be55af7e..67e3cf8bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -16,4 +16,13 @@ public interface ProductAdminApiV1Spec { ApiResponse register( ProductAdminV1Dto.RegisterRequest request ); + + @Operation( + summary = "상품 정보 수정", + description = "등록된 상품의 정보를 수정합니다. 소속 브랜드는 변경할 수 없습니다." + ) + ApiResponse update( + Long productId, + ProductAdminV1Dto.UpdateRequest request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index b65c4e528..35d659c59 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -5,6 +5,8 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -32,4 +34,19 @@ public ApiResponse register( ); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } + + @PatchMapping("/{productId}") + @Override + public ApiResponse update( + @PathVariable Long productId, + @Valid @RequestBody ProductAdminV1Dto.UpdateRequest request) { + ProductInfo info = productFacade.update( + productId, + request.name(), + request.price(), + request.stockQuantity(), + request.description() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index ba2a49f93..d2b7c0f17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -39,6 +39,23 @@ public record RegisterRequest( ) { } + public record UpdateRequest( + @Size(min = 1, max = 200, message = "상품명은 1~200자여야 합니다") + String name, + + @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") + @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") + Integer stockQuantity, + + @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") + String description + ) { + } + // Response public record ProductResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index e6675fdc1..22308a701 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -1,6 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -13,6 +16,7 @@ import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @@ -21,6 +25,9 @@ class ProductServiceIntegrationTest { @Autowired private ProductService productService; + @Autowired + private ProductRepository productRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -45,4 +52,40 @@ class 상품_등록 { assertThat(result.getLikeCount()).isEqualTo(0); } } + + @Nested + class 상품_수정 { + + @Test + void 유효한_정보로_수정하면_성공한다() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + Product result = productService.update(product.getId(), "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + + assertThat(result.getName()).isEqualTo("런닝화"); + assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); + assertThat(result.getStockQuantity()).isEqualTo(200); + assertThat(result.getDescription()).isEqualTo("가벼운 런닝화"); + } + + @Test + void 미존재_상품이면_예외() { + assertThatThrownBy(() -> productService.update(999L, "런닝화", null, null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + + @Test + void 삭제된_상품을_수정하면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + productRepository.save(product); + + assertThatThrownBy(() -> productService.update(product.getId(), "런닝화", null, null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 1c42a943e..6f165aa31 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -32,7 +32,7 @@ class 생성 { } @Test - void likeCount가_0으로_초기화된다() { + void 좋아요수가_0으로_초기화된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); assertThat(product.getLikeCount()).isEqualTo(0); @@ -175,4 +175,164 @@ class 생성 { .doesNotThrowAnyException(); } } + + @Nested + class 수정 { + + @Test + void 상품명만_수정하면_상품명만_변경된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update("런닝화", null, null, null); + + assertThat(product.getName()).isEqualTo("런닝화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(product.getStockQuantity()).isEqualTo(100); + assertThat(product.getDescription()).isEqualTo("편한 운동화"); + } + + @Test + void 가격만_수정하면_가격만_변경된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update(null, new BigDecimal("60000"), null, null); + + assertThat(product.getName()).isEqualTo("운동화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); + assertThat(product.getStockQuantity()).isEqualTo(100); + assertThat(product.getDescription()).isEqualTo("편한 운동화"); + } + + @Test + void 재고수량만_수정하면_재고수량만_변경된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update(null, null, 200, null); + + assertThat(product.getName()).isEqualTo("운동화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(product.getStockQuantity()).isEqualTo(200); + assertThat(product.getDescription()).isEqualTo("편한 운동화"); + } + + @Test + void 설명만_수정하면_설명만_변경된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update(null, null, null, "가벼운 운동화"); + + assertThat(product.getName()).isEqualTo("운동화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(product.getStockQuantity()).isEqualTo(100); + assertThat(product.getDescription()).isEqualTo("가벼운 운동화"); + } + + @Test + void 모든_필드를_수정하면_모두_변경된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + + assertThat(product.getName()).isEqualTo("런닝화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); + assertThat(product.getStockQuantity()).isEqualTo(200); + assertThat(product.getDescription()).isEqualTo("가벼운 런닝화"); + } + + @Test + void 모두_null이면_변경되지_않는다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.update(null, null, null, null); + + assertThat(product.getName()).isEqualTo("운동화"); + assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(product.getStockQuantity()).isEqualTo(100); + assertThat(product.getDescription()).isEqualTo("편한 운동화"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void 상품명이_빈값이면_예외(String name) { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> product.update(name, null, null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품명은 필수입니다"); + } + + @Test + void 상품명이_200자를_초과하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + String longName = "a".repeat(201); + + assertThatThrownBy(() -> product.update(longName, null, null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품명은 200자 이하여야 합니다"); + } + + @Test + void 가격이_음수면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> product.update(null, new BigDecimal("-1"), null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 0 이상이어야 합니다"); + } + + @Test + void 가격이_최대값을_초과하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> product.update(null, new BigDecimal("1000000000"), null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 999,999,999 이하여야 합니다"); + } + + @Test + void 재고수량이_음수면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> product.update(null, null, -1, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고 수량은 0 이상이어야 합니다"); + } + + @Test + void 재고수량이_최대값을_초과하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> product.update(null, null, 10_000_000, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고 수량은 9,999,999 이하여야 합니다"); + } + + @Test + void 설명이_1000자를_초과하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + String longDescription = "a".repeat(1001); + + assertThatThrownBy(() -> product.update(null, null, null, longDescription)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품 설명은 1,000자 이하여야 합니다"); + } + + @Test + void 삭제된_상품을_수정하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + + assertThatThrownBy(() -> product.update("런닝화", null, null, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index ba1f92ae4..37b2f38e2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.product; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -34,6 +36,9 @@ class ProductAdminApiE2ETest { @Autowired private TestRestTemplate testRestTemplate; + @Autowired + private ProductRepository productRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -162,6 +167,161 @@ class 상품_등록 { } } + @Nested + class 상품_수정 { + + @Test + void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화" + ); + + ResponseEntity> response = patchUpdate(productId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("런닝화"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("60000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(200), + () -> assertThat(response.getBody().data().description()).isEqualTo("가벼운 런닝화") + ); + } + + @Test + void 상품명만_보내면_상품명만_수정된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + ResponseEntity> response = patchUpdate(productId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("런닝화"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().description()).isEqualTo("편한 운동화") + ); + } + + @Test + void 소속_브랜드는_변경되지_않는다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + ResponseEntity> response = patchUpdate(productId, request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키") + ); + } + + @Test + void 미존재_상품이면_404_응답() { + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 삭제된_상품이면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "", null, null, null + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.PATCH, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "런닝화", null, null, null + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { @@ -182,6 +342,20 @@ private void deleteBrand(Long brandId) { ); } + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = postRegister(request); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + Product product = productRepository.findById(productId).orElseThrow(); + product.delete(); + productRepository.save(product); + } + private ResponseEntity> postRegister( ProductAdminV1Dto.RegisterRequest request) { return testRestTemplate.exchange( @@ -191,6 +365,15 @@ private ResponseEntity> postRegis ); } + private ResponseEntity> patchUpdate( + Long productId, ProductAdminV1Dto.UpdateRequest request) { + return testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.PATCH, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); diff --git a/docs/design/product/class-diagram.md b/docs/design/product/class-diagram.md index b93c58ff4..2bff515b5 100644 --- a/docs/design/product/class-diagram.md +++ b/docs/design/product/class-diagram.md @@ -45,3 +45,4 @@ classDiagram - **Stock VO**: 재고 0 이상 검증, 차감 시 부족 여부 검증을 캡슐화한다 - likeCount는 Like 도메인에서 동기적으로 증감한다 (비정규화 필드) - brandId만 참조하며, Brand 엔티티를 직접 참조하지 않는다 +- deductStock, increaseLikeCount, decreaseLikeCount는 향후 Feature에서 구현 예정 diff --git a/docs/specs/product/002-product-update.md b/docs/specs/product/002-product-update.md index d43c7f260..a342c0886 100644 --- a/docs/specs/product/002-product-update.md +++ b/docs/specs/product/002-product-update.md @@ -32,6 +32,7 @@ Admin | status | String | ACTIVE | | createdAt | LocalDateTime | 등록일시 | | updatedAt | LocalDateTime | 수정일시 | +| deletedAt | LocalDateTime | 삭제일시 | ## 인수 조건 - [ ] 유효한 정보로 수정하면 200 응답과 수정된 상품 정보를 반환한다 From d9ac1c2a0195f02bb9ef8e1928d47c0534f5a708 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 15:52:28 +0900 Subject: [PATCH 25/68] =?UTF-8?q?feat:=20Product=20=EC=82=AD=EC=A0=9C=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 5 + .../application/product/ProductService.java | 8 ++ .../api/product/ProductAdminApiV1Spec.java | 6 ++ .../api/product/ProductAdminV1Controller.java | 8 ++ .../ProductServiceIntegrationTest.java | 33 +++++++ .../loopers/domain/product/ProductTest.java | 24 +++++ .../api/product/ProductAdminApiE2ETest.java | 98 +++++++++++++++++-- 7 files changed, 174 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index e09d7a9f5..0ec98a5c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -32,4 +32,9 @@ public ProductInfo update(Long productId, String name, BigDecimal price, Integer Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } + + @Transactional + public void delete(Long productId) { + productService.delete(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 17167b709..bc67bb7a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -33,4 +33,12 @@ public Product update(Long productId, String name, BigDecimal price, Integer sto product.update(name, price, stockQuantity, description); return product; } + + @Transactional + public void delete(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + product.validateNotDeleted(); + product.delete(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index 67e3cf8bb..8d1607ad2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -25,4 +25,10 @@ ApiResponse update( Long productId, ProductAdminV1Dto.UpdateRequest request ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다." + ) + ApiResponse delete(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 35d659c59..5ad79ac88 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -5,6 +5,7 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,4 +50,11 @@ public ApiResponse update( ); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete(@PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 22308a701..596a95c4a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -88,4 +88,37 @@ class 상품_수정 { .hasMessageContaining("존재하지 않는 상품입니다"); } } + + @Nested + class 상품_삭제 { + + @Test + void 활성_상품을_삭제하면_삭제_상태로_변경된다() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + productService.delete(product.getId()); + + Product found = productRepository.findById(product.getId()).orElseThrow(); + assertThat(found.isDeleted()).isTrue(); + } + + @Test + void 미존재_상품이면_예외() { + assertThatThrownBy(() -> productService.delete(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + + @Test + void 삭제된_상품이면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + productService.delete(product.getId()); + + assertThatThrownBy(() -> productService.delete(product.getId())) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 6f165aa31..c89796e82 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -335,4 +335,28 @@ class 수정 { .hasMessageContaining("존재하지 않는 상품입니다"); } } + + @Nested + class 삭제 { + + @Test + void 삭제하면_삭제_상태이다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.delete(); + + assertThat(product.isDeleted()).isTrue(); + } + + @Test + void 삭제된_상품에_삭제_검증을_호출하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + + assertThatThrownBy(() -> product.validateNotDeleted()) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 37b2f38e2..85b438974 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,7 +1,5 @@ package com.loopers.interfaces.api.product; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -36,9 +34,6 @@ class ProductAdminApiE2ETest { @Autowired private TestRestTemplate testRestTemplate; - @Autowired - private ProductRepository productRepository; - @Autowired private DatabaseCleanUp databaseCleanUp; @@ -322,6 +317,83 @@ class 상품_수정 { } } + @Nested + class 상품_삭제 { + + @Test + void 활성_상품을_삭제하면_200_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = deleteRequest(productId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 미존재_상품이면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 삭제된_상품이면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.DELETE, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { @@ -351,9 +423,19 @@ private Long registerProduct(Long brandId, String name, BigDecimal price, Intege } private void deleteProduct(Long productId) { - Product product = productRepository.findById(productId).orElseThrow(); - product.delete(); - productRepository.save(product); + testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity> deleteRequest(Long productId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); } private ResponseEntity> postRegister( From 0ba7fa0e6d672b254a503fe76c76e1eaf34287aa Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 15:53:41 +0900 Subject: [PATCH 26/68] =?UTF-8?q?chore:=20implement,=20implement-review,?= =?UTF-8?q?=20test-patterns=20=EC=8A=A4=ED=82=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/implement-review/SKILL.md | 4 +++- .claude/skills/implement/SKILL.md | 7 +++++-- .claude/skills/test-patterns/SKILL.md | 6 +++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.claude/skills/implement-review/SKILL.md b/.claude/skills/implement-review/SKILL.md index 2789caa91..cd63d7e2b 100644 --- a/.claude/skills/implement-review/SKILL.md +++ b/.claude/skills/implement-review/SKILL.md @@ -36,7 +36,9 @@ FIX → 사용자 승인 시 수정 (선택) 1. 요구사항 정의서가 있으면 읽는다: `docs/requirements/{epic}.md` 2. `docs/specs/{epic}/{NNN-feature-name}.md` 읽기 → AC 추출 -3. `docs/design/{epic}/` 관련 문서 읽기 (class-diagram, erd, sequence — 있는 경우만) +3. 설계 문서 읽기 (있는 경우만): + - 루트: `docs/design/domain-map.md`, `docs/design/erd.md` + - Epic: `docs/design/{epic}/` (class-diagram, sequence) 4. 해당 도메인의 구현 코드 전체 읽기 (domain, infra, application, interfaces) 5. 해당 도메인의 테스트 코드 전체 읽기 6. 검증 기준 상기: `references/` 3개 파일 읽기 diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index 35f40a109..41d182cee 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -38,7 +38,10 @@ REPORT → AC 매핑 표 + 파일 목록 보고 1. 요구사항 정의서가 있으면 읽는다: `docs/requirements/{epic}.md` 2. `docs/specs/{epic}/{NNN-feature-name}.md` 파일을 읽는다 -3. 설계 문서가 있으면 함께 읽는다: `docs/design/{epic}/` +3. 설계 문서가 있으면 함께 읽는다: + - 루트: `docs/design/domain-map.md`, `docs/design/erd.md` (있는 경우) + - Epic: `docs/design/{epic}/` (class-diagram, sequence 등 — 있는 경우) + - 설계 문서가 없으면 spec과 rules만으로 진행한다 (별도 안내 불필요) 4. AC를 추출하고 각 AC의 테스트 유형을 분류한다 ### AC → 테스트 유형 매핑 기준 @@ -128,7 +131,7 @@ REPORT → AC 매핑 표 + 파일 목록 보고 ### 절차 -1. `test-patterns` 스킬을 읽고 패턴을 확인한다 +1. `.claude/skills/test-patterns/SKILL.md`를 읽고 테스트 패턴을 확인한다 - 스킬을 찾을 수 없으면 `.claude/rules/` 하위 테스트 관련 규칙과 Phase 4의 필수 준수 사항을 기준으로 작성한다 2. Phase 1에서 분류한 AC별 테스트 유형에 따라 작성한다 diff --git a/.claude/skills/test-patterns/SKILL.md b/.claude/skills/test-patterns/SKILL.md index 0217a6ceb..d83c2ae52 100644 --- a/.claude/skills/test-patterns/SKILL.md +++ b/.claude/skills/test-patterns/SKILL.md @@ -397,7 +397,10 @@ class UserApiE2ETest { ); // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().message()).contains("이미 존재하는 로그인 ID입니다") + ); } @Test @@ -537,6 +540,7 @@ class UserApiE2ETest { - [ ] `RANDOM_PORT` 설정 - [ ] `TestRestTemplate` 사용 - [ ] HTTP 상태 코드 검증 +- [ ] AC에 에러 메시지가 명시되어 있으면 `meta().message()` 검증 - [ ] 스펙(AC)의 모든 상태 코드 시나리오가 E2E에 1:1 매핑되었는지 확인 --- From 0bce797f4e177d32bd741fe90f3713a4e05426a7 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 16:27:37 +0900 Subject: [PATCH 27/68] =?UTF-8?q?feat:=20Product=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=EB=B3=84=20=EC=83=81=ED=92=88=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 3 + .../application/product/ProductFacade.java | 12 + .../application/product/ProductService.java | 15 ++ .../domain/product/ProductRepository.java | 7 + .../product/ProductJpaRepository.java | 21 ++ .../product/ProductRepositoryImpl.java | 13 ++ .../api/product/ProductAdminApiV1Spec.java | 11 + .../api/product/ProductAdminV1Controller.java | 16 ++ .../api/product/ProductAdminV1Dto.java | 39 ++++ .../ProductServiceIntegrationTest.java | 161 +++++++++++++ .../api/brand/BrandAdminApiE2ETest.java | 48 ++++ .../api/product/ProductAdminApiE2ETest.java | 211 ++++++++++++++++++ 12 files changed, 557 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 41827a2c1..cb4b6772f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.brand; +import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,6 +14,7 @@ public class BrandFacade { private final BrandService brandService; + private final ProductService productService; // Command @@ -31,6 +33,7 @@ public BrandInfo update(Long brandId, String name, String description) { @Transactional public void delete(Long brandId) { brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); } // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 0ec98a5c3..add475e82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -4,6 +4,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -37,4 +39,14 @@ public ProductInfo update(Long productId, String name, BigDecimal price, Integer public void delete(Long productId) { productService.delete(productId); } + + // Query + + public Page getList(String name, Long brandId, Boolean deleted, Pageable pageable) { + Page products = productService.findProducts(name, brandId, deleted, pageable); + return products.map(product -> { + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand.getName()); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index bc67bb7a3..486cf619b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -5,10 +5,13 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.util.List; @Service @RequiredArgsConstructor @@ -41,4 +44,16 @@ public void delete(Long productId) { product.validateNotDeleted(); product.delete(); } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(Product::delete); + } + + // Query + + public Page findProducts(String name, Long brandId, Boolean deleted, Pageable pageable) { + return productRepository.findAll(name, brandId, deleted, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 9690bfa8b..73103a8d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,5 +1,9 @@ package com.loopers.domain.product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -9,4 +13,7 @@ public interface ProductRepository { // Query Optional findById(Long id); + List findAllByBrandId(Long brandId); + + Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 0375b7ca7..1db21da59 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,7 +1,28 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface ProductJpaRepository extends JpaRepository { + + // Query + List findAllByBrandId(Long brandId); + + + @Query(value = "SELECT p FROM Product p " + + "WHERE (:name IS NULL OR p.name LIKE %:name%) " + + "AND (:brandId IS NULL OR p.brandId = :brandId) " + + "AND (:deleted IS NULL OR (:deleted = true AND p.deletedAt IS NOT NULL) OR (:deleted = false AND p.deletedAt IS NULL)) " + + "ORDER BY p.createdAt DESC", + countQuery = "SELECT COUNT(p) FROM Product p " + + "WHERE (:name IS NULL OR p.name LIKE %:name%) " + + "AND (:brandId IS NULL OR p.brandId = :brandId) " + + "AND (:deleted IS NULL OR (:deleted = true AND p.deletedAt IS NOT NULL) OR (:deleted = false AND p.deletedAt IS NULL))") + Page findAll(@Param("name") String name, @Param("brandId") Long brandId, @Param("deleted") Boolean deleted, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 1eeb7d6ed..e6b752c61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -3,8 +3,11 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -24,4 +27,14 @@ public Product save(Product product) { public Optional findById(Long id) { return productJpaRepository.findById(id); } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @Override + public Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable) { + return productJpaRepository.findAll(name, brandId, deleted, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index 8d1607ad2..81f25d04b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -31,4 +32,14 @@ ApiResponse update( description = "상품을 삭제합니다." ) ApiResponse delete(Long productId); + + // Query + + @Operation( + summary = "상품 목록 조회", + description = "전체 상품을 검색/필터링하여 페이징 조회합니다." + ) + ApiResponse> list( + ProductAdminV1Dto.ListRequest request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 5ad79ac88..f5735cf1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -3,9 +3,12 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -57,4 +60,17 @@ public ApiResponse delete(@PathVariable Long productId) { productFacade.delete(productId); return ApiResponse.success(); } + + // Query + + @GetMapping + @Override + public ApiResponse> list( + @Valid ProductAdminV1Dto.ListRequest request) { + Page products = productFacade.getList( + request.name(), request.brandId(), request.toDeleted(), request.toPageable()); + PageResponse pageResponse = + PageResponse.from(products, ProductAdminV1Dto.ProductResponse::from); + return ApiResponse.success(pageResponse); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index d2b7c0f17..e922a3d26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -7,7 +7,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -56,6 +59,42 @@ public record UpdateRequest( ) { } + // Query + + public record ListRequest( + String name, + Long brandId, + ProductStatus status, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListRequest { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public enum ProductStatus { + ACTIVE, DELETED; + + public Boolean toDeleted() { + return this == DELETED ? Boolean.TRUE : Boolean.FALSE; + } + } + + public Boolean toDeleted() { + return status != null ? status.toDeleted() : null; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + // Response public record ProductResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 596a95c4a..28d554a28 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -13,9 +13,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @@ -121,4 +126,160 @@ class 상품_삭제 { .hasMessageContaining("존재하지 않는 상품입니다"); } } + + @Nested + class 상품_목록_조회 { + + private static final Pageable DEFAULT_PAGEABLE = PageRequest.of(0, 20); + + @Test + void 조건_없이_조회하면_전체_상품을_최신_등록순으로_페이징하여_반환한다() { + productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); + productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + productService.register(1L, "운동화C", new BigDecimal("30000"), 30, "설명C"); + + Page result = productService.findProducts(null, null, null, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getName()).isEqualTo("운동화C"); + assertThat(result.getContent().get(1).getName()).isEqualTo("운동화B"); + assertThat(result.getContent().get(2).getName()).isEqualTo("운동화A"); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @Test + void 삭제된_상품도_포함하여_반환한다() { + productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleted.delete(); + productRepository.save(deleted); + + Page result = productService.findProducts(null, null, null, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Product::isDeleted) + .containsExactly(true, false); + } + + @Test + void name_키워드로_검색하면_상품명에_해당_키워드가_포함된_상품만_반환한다() { + productService.register(1L, "런닝화", new BigDecimal("10000"), 10, "설명A"); + productService.register(1L, "운동화", new BigDecimal("20000"), 20, "설명B"); + productService.register(1L, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C"); + + Page result = productService.findProducts("런닝", null, null, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Product::getName) + .containsExactly("런닝 슈즈", "런닝화"); + } + + @Test + void brandId로_필터링하면_해당_브랜드에_속한_상품만_반환한다() { + productService.register(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + productService.register(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + productService.register(1L, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + + Page result = productService.findProducts(null, 1L, null, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Product::getBrandId) + .containsOnly(1L); + } + + @Test + void deleted_true로_필터링하면_삭제된_상품만_반환한다() { + productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleted.delete(); + productRepository.save(deleted); + + Page result = productService.findProducts(null, null, true, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("운동화B"); + assertThat(result.getContent().get(0).isDeleted()).isTrue(); + } + + @Test + void deleted_false로_필터링하면_활성_상품만_반환한다() { + productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleted.delete(); + productRepository.save(deleted); + + Page result = productService.findProducts(null, null, false, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("운동화A"); + assertThat(result.getContent().get(0).isDeleted()).isFalse(); + } + + @Test + void 복합_필터를_동시에_적용할_수_있다() { + productService.register(1L, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명"); + Product deleted = productService.register(1L, "나이키 조던", new BigDecimal("20000"), 20, "설명"); + deleted.delete(); + productRepository.save(deleted); + productService.register(2L, "나이키 콜라보", new BigDecimal("30000"), 30, "설명"); + + Page result = productService.findProducts("나이키", 1L, false, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("나이키 에어맥스"); + } + + @Test + void 결과가_없으면_빈_페이지를_반환한다() { + Page result = productService.findProducts("존재하지않는상품", null, null, DEFAULT_PAGEABLE); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } + + @Nested + class 브랜드별_상품_일괄_삭제 { + + @Test + void 해당_브랜드의_활성_상품이_모두_삭제_상태로_변경된다() { + Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product2 = productService.register(1L, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + + productService.deleteAllByBrandId(1L); + + Product found1 = productRepository.findById(product1.getId()).orElseThrow(); + Product found2 = productRepository.findById(product2.getId()).orElseThrow(); + assertThat(found1.isDeleted()).isTrue(); + assertThat(found2.isDeleted()).isTrue(); + } + + @Test + void 해당_브랜드에_상품이_없으면_정상_처리된다() { + assertThatCode(() -> productService.deleteAllByBrandId(999L)) + .doesNotThrowAnyException(); + } + + @Test + void 이미_삭제된_상품도_삭제_상태를_유지한다() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + productService.delete(product.getId()); + + productService.deleteAllByBrandId(1L); + + Product found = productRepository.findById(product.getId()).orElseThrow(); + assertThat(found.isDeleted()).isTrue(); + } + + @Test + void 다른_브랜드의_상품은_영향받지_않는다() { + productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product otherBrandProduct = productService.register(2L, "샌들", new BigDecimal("30000"), 50, "여름 샌들"); + + productService.deleteAllByBrandId(1L); + + Product found = productRepository.findById(otherBrandProduct.getId()).orElseThrow(); + assertThat(found.isDeleted()).isFalse(); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index d7b08690c..24b8e1415 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -2,6 +2,7 @@ import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -18,6 +19,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.math.BigDecimal; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -26,6 +29,7 @@ class BrandAdminApiE2ETest { private static final String ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; private static final String VALID_LDAP = "admin-ldap"; @Autowired @@ -344,6 +348,25 @@ class 브랜드_삭제 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + + @Test + void 브랜드_삭제_시_해당_브랜드의_활성_상품도_삭제_상태로_변경된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + registerProduct(brandId, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + + deleteRequest(brandId); + + ResponseEntity>> response = + getProductList("?brandId=" + brandId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .allSatisfy(product -> assertThat(product.status()).isEqualTo("DELETED")) + ); + } } @Nested @@ -378,6 +401,7 @@ class 브랜드_목록_조회 { ResponseEntity>> response = getList(""); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(2), () -> assertThat(response.getBody().data().content()) .extracting(BrandAdminV1Dto.BrandResponse::status) @@ -395,6 +419,7 @@ class 브랜드_목록_조회 { getList("?name=나이키"); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") ); @@ -410,6 +435,7 @@ class 브랜드_목록_조회 { getList("?status=ACTIVE"); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키"), () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("ACTIVE") @@ -426,6 +452,7 @@ class 브랜드_목록_조회 { getList("?status=DELETED"); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("아디다스"), () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("DELETED") @@ -454,6 +481,7 @@ class 브랜드_목록_조회 { getList("?name=나이키&status=ACTIVE"); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키 에어") ); @@ -632,6 +660,26 @@ private ResponseEntity> getDetail(Lon ); } + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private ResponseEntity>> getProductList(String queryString) { + return testRestTemplate.exchange( + PRODUCT_ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 85b438974..29f945257 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -394,6 +395,208 @@ class 상품_삭제 { } } + @Nested + class 상품_목록_조회 { + + @Test + void 조건_없이_조회하면_전체_상품을_최신_등록순으로_페이징하여_200_응답한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화C"), + () -> assertThat(response.getBody().data().content().get(1).name()).isEqualTo("운동화B"), + () -> assertThat(response.getBody().data().content().get(2).name()).isEqualTo("운동화A"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().page()).isEqualTo(0), + () -> assertThat(response.getBody().data().size()).isEqualTo(20) + ); + } + + @Test + void 삭제된_상품도_포함하여_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleteProduct(deletedProductId); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductAdminV1Dto.ProductResponse::status) + .containsExactly("DELETED", "ACTIVE") + ); + } + + @Test + void name_키워드로_검색하면_상품명에_해당_키워드가_포함된_상품만_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "런닝화", new BigDecimal("10000"), 10, "설명A"); + registerProduct(brandId, "운동화", new BigDecimal("20000"), 20, "설명B"); + registerProduct(brandId, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C"); + + ResponseEntity>> response = + getList("?name=런닝"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductAdminV1Dto.ProductResponse::name) + .containsExactly("런닝 슈즈", "런닝화") + ); + } + + @Test + void brandId로_필터링하면_해당_브랜드에_속한_상품만_반환한다() { + Long nikeId = registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = registerBrand("아디다스", "독일 브랜드"); + registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + + ResponseEntity>> response = + getList("?brandId=" + nikeId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductAdminV1Dto.ProductResponse::brandId) + .containsOnly(nikeId) + ); + } + + @Test + void status_ACTIVE로_필터링하면_활성_상품만_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleteProduct(deletedProductId); + + ResponseEntity>> response = + getList("?status=ACTIVE"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화A"), + () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("ACTIVE") + ); + } + + @Test + void status_DELETED로_필터링하면_삭제된_상품만_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleteProduct(deletedProductId); + + ResponseEntity>> response = + getList("?status=DELETED"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화B"), + () -> assertThat(response.getBody().data().content().get(0).status()).isEqualTo("DELETED") + ); + } + + @Test + void status에_유효하지_않은_값을_보내면_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?status=INVALID", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void name_검색과_brandId_필터와_status_필터를_동시에_적용할_수_있다() { + Long nikeId = registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = registerBrand("아디다스", "독일 브랜드"); + registerProduct(nikeId, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명"); + Long deletedId = registerProduct(nikeId, "나이키 조던", new BigDecimal("20000"), 20, "설명"); + deleteProduct(deletedId); + registerProduct(adidasId, "나이키 콜라보", new BigDecimal("30000"), 30, "설명"); + + ResponseEntity>> response = + getList("?name=나이키&brandId=" + nikeId + "&status=ACTIVE"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키 에어맥스") + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + ResponseEntity>> response = + getList("?name=존재하지않는상품"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { @@ -456,6 +659,14 @@ private ResponseEntity> patchUpda ); } + private ResponseEntity>> getList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); From f66395ef782b53532046dd48bbbced87541b0538 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 17:02:00 +0900 Subject: [PATCH 28/68] =?UTF-8?q?feat:=20Product=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20N+1=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandService.java | 6 ++++ .../application/product/ProductFacade.java | 20 ++++++++++- .../loopers/domain/brand/BrandRepository.java | 3 ++ .../brand/BrandRepositoryImpl.java | 6 ++++ .../brand/BrandServiceIntegrationTest.java | 36 +++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 5a2a1e861..5ff320280 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -67,6 +69,10 @@ public Brand getActiveBrand(Long brandId) { return brand; } + public List getBrands(List brandIds) { + return brandRepository.findAllByIdIn(brandIds); + } + public Page findActiveBrands(String name, Pageable pageable) { return brandRepository.findAllActive(name, pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index add475e82..24a44e894 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,6 +2,8 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.brand.Brand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -10,6 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @Component @RequiredArgsConstructor @@ -44,8 +50,20 @@ public void delete(Long productId) { public Page getList(String name, Long brandId, Boolean deleted, Pageable pageable) { Page products = productService.findProducts(name, brandId, deleted, pageable); + + List brandIds = products.getContent().stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + return products.map(product -> { - Brand brand = brandService.getBrand(product.getBrandId()); + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다"); + } return ProductInfo.from(product, brand.getName()); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index b1135a0fa..0b22a95b6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface BrandRepository { @@ -21,5 +22,7 @@ public interface BrandRepository { Page findAll(String name, Boolean deleted, Pageable pageable); + List findAllByIdIn(List ids); + Page findAllActive(String name, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index a86150212..4d2a11830 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -39,6 +40,11 @@ public boolean existsByNameAndIdNot(String name, Long id) { return brandJpaRepository.existsByNameAndIdNot(name, id); } + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllById(ids); + } + @Override public Page findAll(String name, Boolean deleted, Pageable pageable) { return brandJpaRepository.findAll(name, deleted, pageable); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index 16ee6e375..84949a8c5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -15,6 +15,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import java.util.List; + import static org.assertj.core.api.Assertions.*; @SpringBootTest @@ -273,6 +275,40 @@ class 브랜드_목록_조회 { } } + @Nested + class 브랜드_일괄_조회 { + + @Test + void ID_목록에_해당하는_브랜드들이_반환된다() { + Brand nike = brandService.register("나이키", "스포츠 브랜드"); + Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register("뉴발란스", "미국 스포츠 브랜드"); + + List result = brandService.getBrands(List.of(nike.getId(), adidas.getId())); + + assertThat(result).hasSize(2); + assertThat(result).extracting(Brand::getName) + .containsExactlyInAnyOrder("나이키", "아디다스"); + } + + @Test + void 빈_목록을_전달하면_빈_결과를_반환한다() { + List result = brandService.getBrands(List.of()); + + assertThat(result).isEmpty(); + } + + @Test + void 존재하지_않는_ID가_포함되면_존재하는_것만_반환된다() { + Brand nike = brandService.register("나이키", "스포츠 브랜드"); + + List result = brandService.getBrands(List.of(nike.getId(), 999L)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("나이키"); + } + } + @Nested class 활성_브랜드_조회 { From a6c7ba032a20aa94d6d6f9a4aa70a98d00a059ab Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 17:36:28 +0900 Subject: [PATCH 29/68] =?UTF-8?q?feat:=20User=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC,=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94,?= =?UTF-8?q?=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 16 +++++-- .../loopers/application/user/UserService.java | 11 +++-- .../java/com/loopers/domain/user/User.java | 10 +++++ .../loopers/domain/user/UserRepository.java | 4 ++ .../user/UserJpaRepository.java | 2 + .../user/UserRepositoryImpl.java | 4 ++ .../interfaces/api/user/UserApiV1Spec.java | 16 ++++--- .../interfaces/api/user/UserV1Controller.java | 18 +++++--- .../interfaces/api/user/UserV1Dto.java | 17 ++++--- .../user/UserServiceIntegrationTest.java | 4 +- .../com/loopers/domain/user/UserTest.java | 3 +- .../interfaces/api/user/UserApiE2ETest.java | 45 +++++++++++++++++++ 12 files changed, 121 insertions(+), 29 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 6be9cf088..81a7ca941 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -3,26 +3,34 @@ import com.loopers.domain.user.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @Component @RequiredArgsConstructor +@Transactional(readOnly = true) public class UserFacade { private final UserService userService; + // Command + + @Transactional public UserInfo signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { User user = userService.signUp(loginId, rawPassword, name, birthDate, email); return UserInfo.from(user); } + @Transactional + public void changePassword(Long id, String newRawPassword) { + userService.changePassword(id, newRawPassword); + } + + // Query + public UserInfo getMyInfo(Long id) { User user = userService.getById(id); return UserInfo.from(user); } - - public void changePassword(Long id, String newRawPassword) { - userService.changePassword(id, newRawPassword); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 047e9882c..e761ecef4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -19,6 +19,8 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + // Command + @Transactional public User signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { if (userRepository.existsByLoginId(loginId)) { @@ -31,10 +33,13 @@ public User signUp(String loginId, String rawPassword, String name, LocalDate bi @Transactional public void changePassword(Long id, String newRawPassword) { - User user = getById(id); + User user = userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); user.changePassword(newRawPassword, passwordEncoder); } + // Query + public User getById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); @@ -42,9 +47,9 @@ public User getById(Long id) { public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다")); + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다")); if (!user.matchesPassword(rawPassword, passwordEncoder)) { - throw new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다"); + throw new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다"); } return user; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index f4e8f52c1..23a5a4dda 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -19,7 +19,11 @@ @Getter public class User extends BaseEntity { + private static final int LOGIN_ID_MIN_LENGTH = 4; + private static final int LOGIN_ID_MAX_LENGTH = 20; private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final int NAME_MIN_LENGTH = 2; + private static final int NAME_MAX_LENGTH = 20; private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); @Column(nullable = false, unique = true) @@ -77,6 +81,9 @@ private static void validateLoginId(String loginId) { if (loginId == null || loginId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST,"로그인 ID는 필수입니다"); } + if (loginId.length() < LOGIN_ID_MIN_LENGTH || loginId.length() > LOGIN_ID_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 " + LOGIN_ID_MIN_LENGTH + "~" + LOGIN_ID_MAX_LENGTH + "자여야 합니다"); + } if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { throw new CoreException(ErrorType.BAD_REQUEST,"로그인 ID는 영문/숫자만 가능합니다"); } @@ -86,6 +93,9 @@ private static void validateName(String name) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST,"이름은 필수입니다"); } + if (name.length() < NAME_MIN_LENGTH || name.length() > NAME_MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 " + NAME_MIN_LENGTH + "~" + NAME_MAX_LENGTH + "자여야 합니다"); + } } private static void validateBirthDate(LocalDate birthDate) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 226c05f6c..051f27e56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -4,8 +4,12 @@ public interface UserRepository { + // Command + User save(User user); + // Query + Optional findById(Long id); Optional findByLoginId(String loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 12cdd51b2..ca4b4988e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -7,6 +7,8 @@ public interface UserJpaRepository extends JpaRepository { + // Query + Optional findByLoginId(String loginId); boolean existsByLoginId(String loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 33f98c731..1137c7cc6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -13,11 +13,15 @@ public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; + // Command + @Override public User save(User user) { return userJpaRepository.save(user); } + // Query + @Override public Optional findById(Long id) { return userJpaRepository.findById(id); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java index 1ccde54b4..9452080cd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java @@ -9,18 +9,14 @@ @Tag(name = "User API", description = "회원 관리 API") public interface UserApiV1Spec { + // Command + @Operation( summary = "회원가입", description = "새로운 회원을 등록합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." ) ApiResponse signUp(UserV1Dto.SignUpRequest request); - @Operation( - summary = "내 정보 조회", - description = "로그인한 회원의 정보를 조회합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." - ) - ApiResponse getMyInfo(@Parameter(hidden = true) AuthenticatedUser authUser); - @Operation( summary = "비밀번호 변경", description = "회원의 비밀번호를 변경합니다." @@ -29,4 +25,12 @@ ApiResponse changePassword( @Parameter(hidden = true) AuthenticatedUser authUser, UserV1Dto.ChangePasswordRequest request ); + + // Query + + @Operation( + summary = "내 정보 조회", + description = "로그인한 회원의 정보를 조회합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." + ) + ApiResponse getMyInfo(@Parameter(hidden = true) AuthenticatedUser authUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 85eea13a8..21d47b7cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -21,6 +21,8 @@ public class UserV1Controller implements UserApiV1Spec { private final UserFacade userFacade; + // Command + @PostMapping @Override public ApiResponse signUp(@Valid @RequestBody UserV1Dto.SignUpRequest request) { @@ -34,13 +36,6 @@ public ApiResponse signUp(@Valid @RequestBody UserV1Dto. return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } - @GetMapping("/me") - @Override - public ApiResponse getMyInfo(@AuthUser AuthenticatedUser authUser) { - UserInfo info = userFacade.getMyInfo(authUser.id()); - return ApiResponse.success(UserV1Dto.UserResponse.from(info)); - } - @PatchMapping("/me/password") @Override public ApiResponse changePassword( @@ -49,4 +44,13 @@ public ApiResponse changePassword( userFacade.changePassword(authUser.id(), request.newPassword()); return ApiResponse.success(); } + + // Query + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo(@AuthUser AuthenticatedUser authUser) { + UserInfo info = userFacade.getMyInfo(authUser.id()); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index ebec7d38d..820de6423 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -3,17 +3,22 @@ import com.loopers.application.user.UserInfo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDate; public class UserV1Dto { + // Command + public record SignUpRequest( @NotBlank(message = "로그인 ID는 필수입니다") + @Size(min = 4, max = 20, message = "로그인 ID는 4~20자여야 합니다") String loginId, @NotBlank(message = "비밀번호는 필수입니다") String password, @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다") String name, @NotNull(message = "생년월일은 필수입니다") LocalDate birthDate, @@ -21,6 +26,13 @@ public record SignUpRequest( String email ) {} + public record ChangePasswordRequest( + @NotBlank(message = "새 비밀번호는 필수입니다") + String newPassword + ) {} + + // Response + public record UserResponse( String loginId, String name, @@ -36,9 +48,4 @@ public static UserResponse from(UserInfo info) { ); } } - - public record ChangePasswordRequest( - @NotBlank(message = "새 비밀번호는 필수입니다") - String newPassword - ) {} } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index 14feb7985..054bdaceb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -81,7 +81,7 @@ class 인증 { assertThatThrownBy(() -> userService.authenticate("notexist", "Test1234!")) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)) - .hasMessageContaining("아이디 또는 비밀번호가 일치하지 않습니다"); + .hasMessageContaining("인증에 실패했습니다"); } @Test @@ -92,7 +92,7 @@ class 인증 { assertThatThrownBy(() -> userService.authenticate(loginId, "WrongPass1!")) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)) - .hasMessageContaining("아이디 또는 비밀번호가 일치하지 않습니다"); + .hasMessageContaining("인증에 실패했습니다"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 2c08d0934..2158a5c0d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -114,8 +114,7 @@ class 이름_마스킹 { @ParameterizedTest @CsvSource({ "홍길동, 홍길*", - "김밥, 김*", - "이, *" + "김밥, 김*" }) void 마지막_글자를_마스킹한다(String name, String expected) { User user = User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 4226e285c..04029caa4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -96,6 +96,21 @@ class 회원가입 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } + + @Test + void 비밀번호에_생년월일이_포함되면_400_응답() { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + "testuser", "Abcd20000115!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + + ResponseEntity> response = testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } @Nested @@ -225,6 +240,36 @@ class 비밀번호_변경 { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + + @Test + void 비밀번호에_생년월일이_포함되면_400_응답() { + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Abcd20000115!"); + + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 유효하지_않은_비밀번호면_400_응답() { + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("short"); + + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } // --- 헬퍼 메서드 --- From 8ad1216451e4510003e3ca4617272cde02239877 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 17:38:54 +0900 Subject: [PATCH 30/68] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=93=B1=EB=A1=9D=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20Product=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=88=98=20=EC=B0=A8=EA=B0=90=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 27 ++ .../loopers/application/like/LikeService.java | 42 +++ .../application/product/ProductService.java | 42 +++ .../java/com/loopers/domain/like/Like.java | 51 ++++ .../loopers/domain/like/LikeRepository.java | 13 + .../com/loopers/domain/product/Product.java | 17 ++ .../domain/product/ProductRepository.java | 2 + .../like/LikeJpaRepository.java | 12 + .../like/LikeRepositoryImpl.java | 34 +++ .../product/ProductJpaRepository.java | 7 + .../product/ProductRepositoryImpl.java | 5 + .../interfaces/api/like/LikeApiV1Spec.java | 18 ++ .../interfaces/api/like/LikeV1Controller.java | 30 +++ .../like/LikeServiceIntegrationTest.java | 81 ++++++ .../loopers/domain/product/ProductTest.java | 32 +++ .../interfaces/api/like/LikeApiE2ETest.java | 245 ++++++++++++++++++ 16 files changed, 658 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..4cc2289af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + // Command + + @Transactional + public void like(Long userId, Long productId) { + productService.getActiveProduct(productId); + + boolean created = likeService.like(userId, productId); + if (created) { + productService.incrementLikeCount(productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..73087deed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,42 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeService { + + private final LikeRepository likeRepository; + + // Command + + @Transactional + public boolean like(Long userId, Long productId) { + Optional existing = likeRepository.findByUserIdAndProductId(userId, productId); + if (existing.isPresent()) { + return false; + } + + Like like = Like.create(userId, productId); + likeRepository.save(like); + return true; + } + + @Transactional + public boolean unlike(Long userId, Long productId) { + Optional existing = likeRepository.findByUserIdAndProductId(userId, productId); + if (existing.isEmpty()) { + return false; + } + + likeRepository.delete(existing.get()); + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 486cf619b..aa5f19e5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -11,7 +11,9 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -45,6 +47,39 @@ public void delete(Long productId) { product.delete(); } + @Transactional + public void incrementLikeCount(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + product.incrementLikeCount(); + } + + @Transactional + public void decrementLikeCount(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + product.decrementLikeCount(); + } + + @Transactional + public List deductStocks(Map productQuantities) { + List productIds = new ArrayList<>(productQuantities.keySet()); + List products = productRepository.findAllByIdInForUpdate(productIds); + + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다"); + } + + for (Product product : products) { + if (product.isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다"); + } + product.deductStock(productQuantities.get(product.getId())); + } + + return products; + } + @Transactional public void deleteAllByBrandId(Long brandId) { List products = productRepository.findAllByBrandId(brandId); @@ -53,6 +88,13 @@ public void deleteAllByBrandId(Long brandId) { // Query + public Product getActiveProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + product.validateNotDeleted(); + return product; + } + public Page findProducts(String name, Long brandId, Boolean deleted, Pageable pageable) { return productRepository.findAll(name, brandId, deleted, pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..ea4271c86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,51 @@ +package com.loopers.domain.like; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) +}) +@Getter +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Like() { + } + + private Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + public static Like create(Long userId, Long productId) { + return new Like(userId, productId); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..ffff0dad5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + // Command + Like save(Like like); + void delete(Like like); + + // Query + Optional findByUserIdAndProductId(Long userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 85cf5977d..45a799992 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -59,6 +59,23 @@ public static Product create(Long brandId, String name, BigDecimal price, Intege return new Product(brandId, name, price, stockQuantity, description); } + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void deductStock(int quantity) { + if (this.stockQuantity < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족한 상품이 있습니다"); + } + this.stockQuantity -= quantity; + } + public void update(String name, BigDecimal price, Integer stockQuantity, String description) { validateNotDeleted(); if (name != null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 73103a8d7..a4c09e96d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,5 +15,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + List findAllByIdInForUpdate(List ids); + Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..577f9712e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + // Query + Optional findByUserIdAndProductId(Long userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..63d15c952 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + // Command + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + // Query + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 1db21da59..32a816a5c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -7,11 +7,18 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; + import java.util.List; public interface ProductJpaRepository extends JpaRepository { // Query + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id IN :ids") + List findAllByIdInForUpdate(@Param("ids") List ids); + List findAllByBrandId(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index e6b752c61..502735bae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -23,6 +23,11 @@ public Product save(Product product) { } // Query + @Override + public List findAllByIdInForUpdate(List ids) { + return productJpaRepository.findAllByIdInForUpdate(ids); + } + @Override public Optional findById(Long id) { return productJpaRepository.findById(id); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java new file mode 100644 index 000000000..a0a68e15c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java @@ -0,0 +1,18 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like API", description = "좋아요 API") +public interface LikeApiV1Spec { + + // Command + + @Operation( + summary = "상품 좋아요 등록", + description = "상품에 좋아요를 등록합니다. 이미 좋아요한 상품이면 현재 상태를 유지합니다." + ) + ApiResponse like(Long productId, AuthenticatedUser authUser); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..dc64c0644 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/products/{productId}/likes") +@RequiredArgsConstructor +public class LikeV1Controller implements LikeApiV1Spec { + + private final LikeFacade likeFacade; + + // Command + + @PostMapping + @Override + public ApiResponse like( + @PathVariable Long productId, + @AuthUser AuthenticatedUser authUser) { + likeFacade.like(authUser.id(), productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..964e6c66a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java @@ -0,0 +1,81 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 좋아요_등록 { + + @Test + void 신규_좋아요면_저장되고_생성됨을_반환한다() { + boolean result = likeService.like(1L, 1L); + + assertThat(result).isTrue(); + Optional saved = likeRepository.findByUserIdAndProductId(1L, 1L); + assertThat(saved).isPresent(); + assertThat(saved.get().getUserId()).isEqualTo(1L); + assertThat(saved.get().getProductId()).isEqualTo(1L); + } + + @Test + void 이미_좋아요한_상태이면_생성되지_않는다() { + likeService.like(1L, 1L); + + boolean result = likeService.like(1L, 1L); + + assertThat(result).isFalse(); + } + } + + @Nested + class 좋아요_취소 { + + @Test + void 좋아요가_존재하면_물리적_삭제된다() { + likeService.like(1L, 1L); + + boolean result = likeService.unlike(1L, 1L); + + assertThat(result).isTrue(); + Optional found = likeRepository.findByUserIdAndProductId(1L, 1L); + assertThat(found).isEmpty(); + } + + @Test + void 좋아요가_없으면_삭제되지_않는다() { + boolean result = likeService.unlike(1L, 1L); + + assertThat(result).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index c89796e82..78cebe731 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -336,6 +336,38 @@ class 수정 { } } + @Nested + class 재고_차감 { + + @Test + void 재고가_충분하면_차감된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.deductStock(30); + + assertThat(product.getStockQuantity()).isEqualTo(70); + } + + @Test + void 재고가_정확히_일치하면_0이_된다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.deductStock(100); + + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @Test + void 재고가_부족하면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화"); + + assertThatThrownBy(() -> product.deductStock(11)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고가 부족한 상품이 있습니다"); + } + } + @Nested class 삭제 { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java new file mode 100644 index 000000000..c68e80838 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -0,0 +1,245 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class LikeApiE2ETest { + + private static final String LIKE_ENDPOINT = "/api/v1/products/{productId}/likes"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String USER_ENDPOINT = "/api/v1/users"; + private static final String VALID_LDAP = "admin-ldap"; + + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Test1234!"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 좋아요_등록 { + + @Test + void 활성_상품에_좋아요를_등록하면_200_응답() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = postLike(productId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 좋아요_등록_시_해당_상품의_좋아요_수가_1_증가한다() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = postLike(productId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); + } + + @Test + void 이미_좋아요한_상품에_재요청하면_200_응답하고_좋아요_수가_변동되지_않는다() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + postLike(productId); + ResponseEntity> response = postLike(productId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); + } + ); + } + + @Test + void 삭제된_상품에_좋아요_등록하면_404_응답() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + productId + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 미존재_상품에_좋아요_등록하면_404_응답() { + signUpUser(); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + 999L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {}, + 1L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {}, + 1L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + + // --- 헬퍼 메서드 --- + + private void signUpUser() { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + LOGIN_ID, LOGIN_PW, "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + testRestTemplate.exchange( + USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity> postLike(Long productId) { + return testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + productId + ); + } + + private ResponseEntity>> getProductList(String queryString) { + return testRestTemplate.exchange( + PRODUCT_ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders userHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", LOGIN_PW); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } +} From 599d580d34258ab51be24e552b406a4f701e6895 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 17:56:22 +0900 Subject: [PATCH 31/68] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 10 ++ .../interfaces/api/like/LikeApiV1Spec.java | 6 + .../interfaces/api/like/LikeV1Controller.java | 10 ++ .../interfaces/api/like/LikeApiE2ETest.java | 129 ++++++++++++++++++ 4 files changed, 155 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 4cc2289af..7d9742a94 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -24,4 +24,14 @@ public void like(Long userId, Long productId) { productService.incrementLikeCount(productId); } } + + @Transactional + public void unlike(Long userId, Long productId) { + productService.getActiveProduct(productId); + + boolean deleted = likeService.unlike(userId, productId); + if (deleted) { + productService.decrementLikeCount(productId); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java index a0a68e15c..9725f3649 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java @@ -15,4 +15,10 @@ public interface LikeApiV1Spec { description = "상품에 좋아요를 등록합니다. 이미 좋아요한 상품이면 현재 상태를 유지합니다." ) ApiResponse like(Long productId, AuthenticatedUser authUser); + + @Operation( + summary = "상품 좋아요 취소", + description = "상품의 좋아요를 취소합니다. 좋아요하지 않은 상품이면 현재 상태를 유지합니다." + ) + ApiResponse unlike(Long productId, AuthenticatedUser authUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index dc64c0644..8cb91255a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -6,6 +6,7 @@ import com.loopers.interfaces.api.auth.AuthenticatedUser; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -27,4 +28,13 @@ public ApiResponse like( likeFacade.like(authUser.id(), productId); return ApiResponse.success(); } + + @DeleteMapping + @Override + public ApiResponse unlike( + @PathVariable Long productId, + @AuthUser AuthenticatedUser authUser) { + likeFacade.unlike(authUser.id(), productId); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index c68e80838..b02dfc0b2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -170,6 +170,126 @@ class 좋아요_등록 { } } + @Nested + class 좋아요_취소 { + + @Test + void 좋아요한_상품의_좋아요를_취소하면_200_응답() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postLike(productId); + + ResponseEntity> response = deleteLike(productId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 좋아요_취소_시_해당_상품의_좋아요_수가_1_감소한다() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postLike(productId); + + ResponseEntity> response = deleteLike(productId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + } + + @Test + void 좋아요하지_않은_상품에_취소_요청하면_200_응답하고_좋아요_수가_변동되지_않는다() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = deleteLike(productId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + } + ); + } + + @Test + void 삭제된_상품에_좋아요_취소하면_404_응답() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.DELETE, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + productId + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 미존재_상품에_좋아요_취소하면_404_응답() { + signUpUser(); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.DELETE, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + 999L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.DELETE, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {}, + 1L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {}, + 1L + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + // --- 헬퍼 메서드 --- private void signUpUser() { @@ -213,6 +333,15 @@ private void deleteProduct(Long productId) { ); } + private ResponseEntity> deleteLike(Long productId) { + return testRestTemplate.exchange( + LIKE_ENDPOINT, HttpMethod.DELETE, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {}, + productId + ); + } + private ResponseEntity> postLike(Long productId) { return testRestTemplate.exchange( LIKE_ENDPOINT, HttpMethod.POST, From 98ee3cb8725ba93fee90ab6ffb714d63a4cc024c Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 17:58:06 +0900 Subject: [PATCH 32/68] =?UTF-8?q?docs:=20Product=C2=B7Order=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8?= =?UTF-8?q?=20VO=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9B=90=EC=8B=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=8B=A8=EC=88=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/order/class-diagram.md | 15 +++----------- docs/design/product/class-diagram.md | 30 +++++++--------------------- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/docs/design/order/class-diagram.md b/docs/design/order/class-diagram.md index d65bedbbd..66482b4de 100644 --- a/docs/design/order/class-diagram.md +++ b/docs/design/order/class-diagram.md @@ -9,7 +9,7 @@ classDiagram class Order { -Long userId - -Money totalAmount + -BigDecimal totalAmount -List~OrderItem~ orderItems +create(userId, orderItems)$ Order } @@ -18,22 +18,13 @@ classDiagram -Long productId -String productName -String brandName - -Money price + -BigDecimal price -int quantity +create(productId, productName, brandName, price, quantity)$ OrderItem - +getOrderPrice() Money - } - - class Money { - <> - -BigDecimal amount - +add(Money) Money - +multiply(int) Money + +getOrderPrice() BigDecimal } Order *-- "1..*" OrderItem - Order *-- Money - OrderItem *-- Money OrderItem ..> Product : productId 참조 Order ..> User : userId 참조 ``` diff --git a/docs/design/product/class-diagram.md b/docs/design/product/class-diagram.md index 2bff515b5..1c841db9c 100644 --- a/docs/design/product/class-diagram.md +++ b/docs/design/product/class-diagram.md @@ -11,38 +11,22 @@ classDiagram -Long brandId -String name -String description - -Money price - -Stock stockQuantity + -BigDecimal price + -Integer stockQuantity -int likeCount +create(brandId, name, description, price, stockQuantity)$ Product +update(name, price, stockQuantity, description) +delete() +deductStock(int quantity) - +increaseLikeCount() - +decreaseLikeCount() + +incrementLikeCount() + +decrementLikeCount() } - - class Money { - <> - -BigDecimal amount - +add(Money) Money - +multiply(int) Money - } - - class Stock { - <> - -int quantity - +deduct(int) Stock - } - - Product *-- Money - Product *-- Stock ``` ## 설계 결정 -- **Money VO**: 가격 0 이상 검증, 주문 총액 계산(add, multiply) 연산을 캡슐화한다 -- **Stock VO**: 재고 0 이상 검증, 차감 시 부족 여부 검증을 캡슐화한다 +- **price**: BigDecimal로 관리하며, Entity 내부에서 0 이상·상한 검증을 수행한다 (Money VO는 Order 도메인 구현 시 필요에 따라 도입) +- **stockQuantity**: Integer로 관리하며, Entity 내부에서 0 이상·상한 검증 및 차감 로직을 수행한다 (Stock VO는 Order 도메인 구현 시 필요에 따라 도입) - likeCount는 Like 도메인에서 동기적으로 증감한다 (비정규화 필드) - brandId만 참조하며, Brand 엔티티를 직접 참조하지 않는다 -- deductStock, increaseLikeCount, decreaseLikeCount는 향후 Feature에서 구현 예정 +- deductStock, incrementLikeCount, decrementLikeCount는 향후 Feature에서 구현 예정 From 9354271353c1def58f7d7d9ee5ba3d2c0ea10e1d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 25 Feb 2026 21:52:22 +0900 Subject: [PATCH 33/68] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=C2=B7?= =?UTF-8?q?E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 39 ++++++ .../application/like/LikeProductInfo.java | 35 ++++++ .../loopers/application/like/LikeService.java | 8 ++ .../loopers/domain/like/LikeRepository.java | 4 + .../like/LikeJpaRepository.java | 11 ++ .../like/LikeRepositoryImpl.java | 7 ++ .../interfaces/api/like/LikeApiV1Spec.java | 10 ++ .../interfaces/api/like/LikeV1Controller.java | 24 +++- .../interfaces/api/like/LikeV1Dto.java | 60 ++++++++++ .../like/LikeServiceIntegrationTest.java | 50 ++++++++ .../interfaces/api/like/LikeApiE2ETest.java | 112 ++++++++++++++++++ 11 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 7d9742a94..39e7611c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,10 +1,21 @@ package com.loopers.application.like; +import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @Component @RequiredArgsConstructor @Transactional(readOnly = true) @@ -12,6 +23,7 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; + private final BrandService brandService; // Command @@ -34,4 +46,31 @@ public void unlike(Long userId, Long productId) { productService.decrementLikeCount(productId); } } + + // Query + + public Page getLikedProducts(Long userId, Pageable pageable) { + Page likes = likeService.findLikedProducts(userId, pageable); + + List productIds = likes.getContent().stream() + .map(Like::getProductId) + .toList(); + + Map productMap = productService.getProducts(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + List brandIds = productMap.values().stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return likes.map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return LikeProductInfo.from(product, brand.getName()); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java new file mode 100644 index 000000000..3f5727c23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java @@ -0,0 +1,35 @@ +package com.loopers.application.like; + +import com.loopers.domain.product.Product; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record LikeProductInfo( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static LikeProductInfo from(Product product, String brandName) { + return new LikeProductInfo( + product.getId(), + product.getBrandId(), + brandName, + product.getName(), + product.getPrice(), + product.getStockQuantity(), + product.getDescription(), + product.getLikeCount(), + product.getCreatedAt().toLocalDateTime(), + product.getUpdatedAt().toLocalDateTime() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 73087deed..6281401d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -3,6 +3,8 @@ import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,4 +41,10 @@ public boolean unlike(Long userId, Long productId) { likeRepository.delete(existing.get()); return true; } + + // Query + + public Page findLikedProducts(Long userId, Pageable pageable) { + return likeRepository.findAllByUserIdWithActiveProduct(userId, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index ffff0dad5..0beda8d11 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -1,5 +1,8 @@ package com.loopers.domain.like; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.Optional; public interface LikeRepository { @@ -10,4 +13,5 @@ public interface LikeRepository { // Query Optional findByUserIdAndProductId(Long userId, Long productId); + Page findAllByUserIdWithActiveProduct(Long userId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index 577f9712e..a90108fba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -1,7 +1,11 @@ package com.loopers.infrastructure.like; import com.loopers.domain.like.Like; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -9,4 +13,11 @@ public interface LikeJpaRepository extends JpaRepository { // Query Optional findByUserIdAndProductId(Long userId, Long productId); + + @Query(value = "SELECT l FROM Like l WHERE l.userId = :userId " + + "AND l.productId IN (SELECT p.id FROM Product p WHERE p.deletedAt IS NULL) " + + "ORDER BY l.createdAt DESC", + countQuery = "SELECT COUNT(l) FROM Like l WHERE l.userId = :userId " + + "AND l.productId IN (SELECT p.id FROM Product p WHERE p.deletedAt IS NULL)") + Page findAllByUserIdWithActiveProduct(@Param("userId") Long userId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 63d15c952..64f6589f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -3,6 +3,8 @@ import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -31,4 +33,9 @@ public void delete(Like like) { public Optional findByUserIdAndProductId(Long userId, Long productId) { return likeJpaRepository.findByUserIdAndProductId(userId, productId); } + + @Override + public Page findAllByUserIdWithActiveProduct(Long userId, Pageable pageable) { + return likeJpaRepository.findAllByUserIdWithActiveProduct(userId, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java index 9725f3649..19526bde2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.like; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -21,4 +22,13 @@ public interface LikeApiV1Spec { description = "상품의 좋아요를 취소합니다. 좋아요하지 않은 상품이면 현재 상태를 유지합니다." ) ApiResponse unlike(Long productId, AuthenticatedUser authUser); + + // Query + + @Operation( + summary = "좋아요 상품 목록 조회", + description = "사용자가 좋아요한 상품 목록을 좋아요 등록순(최신순)으로 페이징하여 조회합니다." + ) + ApiResponse> list( + LikeV1Dto.ListRequest request, AuthenticatedUser authUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 8cb91255a..0faeec09c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,18 +1,21 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeProductInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/products/{productId}/likes") @RequiredArgsConstructor public class LikeV1Controller implements LikeApiV1Spec { @@ -20,7 +23,7 @@ public class LikeV1Controller implements LikeApiV1Spec { // Command - @PostMapping + @PostMapping("/api/v1/products/{productId}/likes") @Override public ApiResponse like( @PathVariable Long productId, @@ -29,7 +32,7 @@ public ApiResponse like( return ApiResponse.success(); } - @DeleteMapping + @DeleteMapping("/api/v1/products/{productId}/likes") @Override public ApiResponse unlike( @PathVariable Long productId, @@ -37,4 +40,15 @@ public ApiResponse unlike( likeFacade.unlike(authUser.id(), productId); return ApiResponse.success(); } + + // Query + + @GetMapping("/api/v1/likes") + @Override + public ApiResponse> list( + @Valid LikeV1Dto.ListRequest request, + @AuthUser AuthenticatedUser authUser) { + Page likedProducts = likeFacade.getLikedProducts(authUser.id(), request.toPageable()); + return ApiResponse.success(PageResponse.from(likedProducts, LikeV1Dto.LikeProductResponse::from)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..98d33f48a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeProductInfo; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class LikeV1Dto { + + // Query + + public record ListRequest( + @PositiveOrZero Integer page, + @Min(1) @Max(100) Integer size + ) { + public ListRequest { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + + // Response + + public record LikeProductResponse( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static LikeProductResponse from(LikeProductInfo info) { + return new LikeProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stockQuantity(), + info.description(), + info.likeCount(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java index 964e6c66a..7dba3ddef 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java @@ -2,6 +2,8 @@ import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -10,7 +12,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import java.math.BigDecimal; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -25,6 +30,9 @@ class LikeServiceIntegrationTest { @Autowired private LikeRepository likeRepository; + @Autowired + private ProductRepository productRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -78,4 +86,46 @@ class 좋아요_취소 { assertThat(result).isFalse(); } } + + @Nested + class 좋아요_목록_조회 { + + @Test + void 좋아요한_상품을_최신순으로_조회한다() { + Product product1 = productRepository.save(Product.create(1L, "상품1", new BigDecimal("10000"), 10, "설명1")); + Product product2 = productRepository.save(Product.create(1L, "상품2", new BigDecimal("20000"), 20, "설명2")); + likeService.like(1L, product1.getId()); + likeService.like(1L, product2.getId()); + + Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).getProductId()).isEqualTo(product2.getId()); + assertThat(result.getContent().get(1).getProductId()).isEqualTo(product1.getId()); + } + + @Test + void 삭제된_상품은_조회되지_않는다() { + Product product1 = productRepository.save(Product.create(1L, "상품1", new BigDecimal("10000"), 10, "설명1")); + Product product2 = productRepository.save(Product.create(1L, "상품2", new BigDecimal("20000"), 20, "설명2")); + likeService.like(1L, product1.getId()); + likeService.like(1L, product2.getId()); + product2.delete(); + productRepository.save(product2); + + Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getProductId()).isEqualTo(product1.getId()); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + void 좋아요가_없으면_빈_목록을_반환한다() { + Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index b02dfc0b2..23ce14f67 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -32,6 +32,7 @@ class LikeApiE2ETest { private static final String LIKE_ENDPOINT = "/api/v1/products/{productId}/likes"; + private static final String LIKE_LIST_ENDPOINT = "/api/v1/likes"; private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; private static final String USER_ENDPOINT = "/api/v1/users"; @@ -290,6 +291,109 @@ class 좋아요_취소 { } } + @Nested + class 좋아요_목록_조회 { + + @Test + void 좋아요한_상품_목록을_좋아요_등록순으로_페이징하여_200_응답() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); + postLike(productId1); + postLike(productId2); + + ResponseEntity>> response = getLikeList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("슬리퍼"), + () -> assertThat(response.getBody().data().content().get(1).name()).isEqualTo("운동화"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2), + () -> assertThat(response.getBody().data().content().get(0).brandName()).isEqualTo("나이키") + ); + } + + @Test + void 활성_상품만_반환한다() { + signUpUser(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); + postLike(productId1); + postLike(productId2); + deleteProduct(productId2); + + ResponseEntity>> response = getLikeList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1) + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + signUpUser(); + + ResponseEntity>> response = getLikeList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isZero() + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + signUpUser(); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_LIST_ENDPOINT + "?page=-1", HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + LIKE_LIST_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + LIKE_LIST_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + // --- 헬퍼 메서드 --- private void signUpUser() { @@ -351,6 +455,14 @@ private ResponseEntity> postLike(Long productId) { ); } + private ResponseEntity>> getLikeList(String queryString) { + return testRestTemplate.exchange( + LIKE_LIST_ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private ResponseEntity>> getProductList(String queryString) { return testRestTemplate.exchange( PRODUCT_ENDPOINT + queryString, HttpMethod.GET, From 72c17ab2bbd818b816af6f47db736dfc02e3b8bd Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 08:43:53 +0900 Subject: [PATCH 34/68] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 6 ++ .../application/product/ProductService.java | 9 ++ .../domain/product/ProductRepository.java | 1 + .../product/ProductJpaRepository.java | 2 + .../product/ProductRepositoryImpl.java | 5 + .../api/product/ProductAdminApiV1Spec.java | 6 ++ .../api/product/ProductAdminV1Controller.java | 7 ++ .../api/product/ProductAdminApiE2ETest.java | 97 +++++++++++++++++++ 8 files changed, 133 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 24a44e894..099d121fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -48,6 +48,12 @@ public void delete(Long productId) { // Query + public ProductInfo getDetail(Long productId) { + Product product = productService.getProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand.getName()); + } + public Page getList(String name, Long brandId, Boolean deleted, Pageable pageable) { Page products = productService.findProducts(name, brandId, deleted, pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index aa5f19e5b..3b927cc84 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -88,6 +88,11 @@ public void deleteAllByBrandId(Long brandId) { // Query + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); + } + public Product getActiveProduct(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); @@ -98,4 +103,8 @@ public Product getActiveProduct(Long productId) { public Page findProducts(String name, Long brandId, Boolean deleted, Pageable pageable) { return productRepository.findAll(name, brandId, deleted, pageable); } + + public List getProducts(List productIds) { + return productRepository.findAllByIdIn(productIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index a4c09e96d..5e475c839 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,6 +15,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + List findAllByIdIn(List ids); List findAllByIdInForUpdate(List ids); Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 32a816a5c..881236b92 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -15,6 +15,8 @@ public interface ProductJpaRepository extends JpaRepository { // Query + List findAllByIdIn(List ids); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id IN :ids") List findAllByIdInForUpdate(@Param("ids") List ids); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 502735bae..99ea0a18a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -23,6 +23,11 @@ public Product save(Product product) { } // Query + @Override + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdIn(ids); + } + @Override public List findAllByIdInForUpdate(List ids) { return productJpaRepository.findAllByIdInForUpdate(ids); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index 81f25d04b..bf7ee9a70 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -35,6 +35,12 @@ ApiResponse update( // Query + @Operation( + summary = "상품 상세 조회", + description = "특정 상품의 상세 정보를 조회합니다. 삭제된 상품도 조회할 수 있습니다." + ) + ApiResponse detail(Long productId); + @Operation( summary = "상품 목록 조회", description = "전체 상품을 검색/필터링하여 페이징 조회합니다." diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index f5735cf1d..f11766d85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -63,6 +63,13 @@ public ApiResponse delete(@PathVariable Long productId) { // Query + @GetMapping("/{productId}") + @Override + public ApiResponse detail(@PathVariable Long productId) { + ProductInfo info = productFacade.getDetail(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); + } + @GetMapping @Override public ApiResponse> list( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 29f945257..11c507207 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -395,6 +395,95 @@ class 상품_삭제 { } } + @Nested + class 상품_상세_조회 { + + @Test + void 활성_상품을_조회하면_200_응답과_상품_상세_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = getDetail(productId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("운동화"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().description()).isEqualTo("편한 운동화"), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0), + () -> assertThat(response.getBody().data().status()).isEqualTo("ACTIVE"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @Test + void 삭제된_상품도_조회할_수_있으며_status가_DELETED로_표시된다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ResponseEntity> response = getDetail(productId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().status()).isEqualTo("DELETED"), + () -> assertThat(response.getBody().data().deletedAt()).isNotNull() + ); + } + + @Test + void 해당_ID의_상품_데이터가_존재하지_않으면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + @Nested class 상품_목록_조회 { @@ -659,6 +748,14 @@ private ResponseEntity> patchUpda ); } + private ResponseEntity> getDetail(Long productId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private ResponseEntity>> getList(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, HttpMethod.GET, From 8b41ea3e2b5967e63dfdb71e3db3b486718e18d8 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 19:41:26 +0900 Subject: [PATCH 35/68] =?UTF-8?q?docs:=20DTO=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=8B=A0=EC=84=A4=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B7=9C=EC=B9=99=20=EC=9A=A9=EC=96=B4=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/code-ordering.md | 5 +- .claude/rules/conventions/dto.md | 104 +++++++++++++++++++++ .claude/rules/conventions/validation.md | 6 +- .claude/rules/project/architecture.md | 9 +- 4 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 .claude/rules/conventions/dto.md diff --git a/.claude/rules/conventions/code-ordering.md b/.claude/rules/conventions/code-ordering.md index 386fcca68..b9c493452 100644 --- a/.claude/rules/conventions/code-ordering.md +++ b/.claude/rules/conventions/code-ordering.md @@ -8,7 +8,6 @@ ## 계층별 적용 (ApplicationService, Facade, Controller, Repository 등) - 구분 주석: `// Command` → `// Query` 순서 -- DTO는 `// Command` → `// Query` → `// Response` | 계층 | 순서 | 비고 | |------|------|------| @@ -19,7 +18,9 @@ | Repository (interface) | `// Command` → `// Query` | save → find, exists | | RepositoryImpl | `// Command` → `// Query` | Repository 인터페이스와 동일 순서 | | JpaRepository | `// Query` 만 | 상속 메서드 생략, 직접 정의한 메서드만 기재 | -| DTO | `// Command` → `// Query` → `// Response` | Request → Query 파라미터 → Response | +| Request | `// Command` → `// Query` | Place, Cancel → ListByUser | +| Command | 구분 주석 없음 | 기능별 나열 | +| V1Dto | `// Response` 만 | Response 전용 | ## Entity 멤버 순서 diff --git a/.claude/rules/conventions/dto.md b/.claude/rules/conventions/dto.md new file mode 100644 index 000000000..c51e52301 --- /dev/null +++ b/.claude/rules/conventions/dto.md @@ -0,0 +1,104 @@ +# DTO + +## DTO 종류와 소속 계층 + +| DTO | 소속 | 역할 | 형태 | +|-----|------|------|------| +| `Request` | application | Facade 입력 (명령 + 조회) | `Place`, `Cancel`, `ListByUser` 등 | +| `Command` | application | Service 입력 (비즈니스 보강) | `Create`, `Update` 등 | +| `Info` | application | Facade 출력 → Controller | 도메인 데이터의 읽기 전용 표현 | +| `V1Dto` | interfaces | HTTP 응답 전용 | `XxxResponse` | + +- 모든 DTO는 Java record로 정의한다 (불변 보장) +- Entity는 application 계층 밖으로 노출하지 않는다 + +## 데이터 흐름 +``` +Controller Facade Service +Request.Place → @Valid Request.Place → + Command.Create → Command.Create → Entity + Info ← Entity ← +V1Dto.Response ← Info +``` + +1. **Controller → Facade**: `Request`를 `@RequestBody`로 직접 받아 Facade에 전달 +2. **Facade → Service**: `Request`를 DB 조회 등으로 보강하여 `Command`로 재조립하여 전달 +3. **Service → Facade**: Entity 반환 +4. **Facade → Controller**: Entity를 `Info`로 변환하여 반환 +5. **Controller → 클라이언트**: `Info`를 `V1Dto.Response`로 변환 + +## Request 구조 + +Request는 도메인별로 하나의 클래스에 중첩 record로 정의한다. +Controller에서 `@RequestBody`로 직접 받으며, Facade에서 `@Validated`로 검증한다. +```java +public record OrderRequest() { + + // Command + public record Place( + @NotNull @Size(min = 1, max = 100) + List<@Valid PlaceItem> orderItems + ) {} + public record PlaceItem( + @NotNull Long productId, + @NotNull @Min(1) @Max(9999999) Integer quantity + ) {} + public record Cancel(String cancelReason) {} + + // Query + public record ListByUser(LocalDate startDate, LocalDate endDate, Integer page, Integer size) {} +} +``` + +| 구분 | 용도 | 데이터 특성 | +|------|------|-------------| +| `// Command` | 명령 요청 (POST/PATCH/DELETE) | 사용자 입력값 (ID, 수량 등) | +| `// Query` | 조회 파라미터 (GET) | 필터, 페이징 등 | + +## Command 구조 + +Command는 도메인별로 하나의 클래스에 중첩 record로 정의한다. +Facade가 Request를 DB 조회 결과 등으로 보강하여 생성한다. +```java +public record OrderCommand() { + public record Create(Long userId, List items) {} + public record CreateOrderItem(Long productId, String productName, BigDecimal price, int quantity) {} + public record Cancel(Long userId, Long orderId, String cancelReason) {} +} +``` + +| 구분 | 용도 | 데이터 특성 | +|------|------|-------------| +| `Create`, `Update` 등 | Facade → Service | 비즈니스 데이터 포함 (이름, 가격 등) | + +## Info 구조 + +Info는 도메인별로 하나의 record에 중첩 record로 정의한다. +```java +public record OrderInfo( + Long id, + BigDecimal totalAmount, + List orderItems, + ZonedDateTime createdAt +) { + public record OrderItemInfo(...) {} + + public static OrderInfo from(Order order) { ... } +} +``` + +- `from(Entity)` 정적 팩토리 메서드로 Entity → Info 변환 +- Facade에서 변환 책임을 가진다 + +## V1Dto 구조 + +V1Dto는 Response 전용이다. Request는 application 계층의 `Request`를 사용한다. +```java +public class OrderV1Dto { + + // Response + public record OrderResponse(...) { + public static OrderResponse from(OrderInfo info) { ... } + } +} +``` \ No newline at end of file diff --git a/.claude/rules/conventions/validation.md b/.claude/rules/conventions/validation.md index 4a8116bba..e6da91c88 100644 --- a/.claude/rules/conventions/validation.md +++ b/.claude/rules/conventions/validation.md @@ -4,12 +4,14 @@ | 위치 | 역할 | 예시 | |---|---|---| -| `@Valid` (DTO) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | +| `@Valid` (Request) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | +| Facade (`@Validated`) | Request 검증 경계 | 클래스 레벨 `@Validated`로 `@Valid` 트리거 | | Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | | ApplicationService | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | - `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) -- Entity가 검증의 최종 방어선 — DTO 검증이 빠져도 Entity에서 반드시 잡아야 함 +- Entity가 검증의 최종 방어선 — Request 검증이 빠져도 Entity에서 반드시 잡아야 함 +- Facade에 `@Validated`를 선언하여, `@Valid` 파라미터를 Facade 진입 시점에 검증한다 ## 예외 처리 diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index 03215d63c..c1b96d4e0 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -3,8 +3,8 @@ ## 패키지 구조 (DDD) ``` com.loopers/ -├── interfaces/ # REST 컨트롤러, Request/Response DTO -├── application/ # Facade, ApplicationService(xxxService), Info DTO +├── interfaces/ # REST 컨트롤러, Response DTO (V1Dto) +├── application/ # Facade, ApplicationService(xxxService), Request, Command, Info ├── domain/ # Entity, Domain Service, Repository 인터페이스, VO ├── infrastructure/ # Repository 구현체, 외부 어댑터 └── support/ # 횡단 관심사 (에러, 유틸, 글로벌 핸들러) @@ -37,13 +37,14 @@ interfaces → application → domain ← infrastructure ### Controller (interfaces) - HTTP 요청/응답 변환만 담당 -- Request에서 원시값 추출하여 Facade에 전달 -- API별 enum은 Request/Response DTO 내부에 inner enum으로 정의 +- `Request`를 `@RequestBody`로 직접 받아 Facade에 전달 (상세: `conventions/dto.md`) +- API별 enum은 Response DTO 내부에 inner enum으로 정의 - 검증 및 예외 처리 규칙은 `conventions/validation.md` 참고 ### Facade (application) - 여러 도메인의 ApplicationService 호출 오케스트레이션 - 트랜잭션 경계 (`@Transactional`) +- 클래스 레벨 `@Validated`로 Request 검증 경계 역할 (상세: `conventions/validation.md`) - Domain Entity → Info DTO 변환 - 다른 도메인의 **ApplicationService만** 호출 (Repository 직접 호출 금지) - Controller는 **항상 Facade만 호출** (일관성 유지, Entity가 Controller에 노출되지 않음) From f33c7b6b422903dc87e1b8f70be61297f29ba2a1 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:04:13 +0900 Subject: [PATCH 36/68] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=C2=B7=ED=86=B5=ED=95=A9=C2=B7E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderCommand.java | 28 ++ .../application/order/OrderFacade.java | 68 ++++ .../loopers/application/order/OrderInfo.java | 47 +++ .../application/order/OrderRequest.java | 32 ++ .../application/order/OrderService.java | 33 ++ .../application/product/ProductService.java | 12 +- .../java/com/loopers/domain/order/Order.java | 90 +++++ .../com/loopers/domain/order/OrderItem.java | 115 ++++++ .../loopers/domain/order/OrderRepository.java | 7 + .../order/OrderJpaRepository.java | 7 + .../order/OrderRepositoryImpl.java | 19 + .../product/ProductJpaRepository.java | 11 +- .../product/ProductRepositoryImpl.java | 8 +- .../interfaces/api/ApiControllerAdvice.java | 10 + .../interfaces/api/order/OrderApiV1Spec.java | 22 ++ .../api/order/OrderV1Controller.java | 32 ++ .../interfaces/api/order/OrderV1Dto.java | 51 +++ .../order/OrderServiceIntegrationTest.java | 76 ++++ .../loopers/domain/order/OrderItemTest.java | 105 ++++++ .../com/loopers/domain/order/OrderTest.java | 84 +++++ .../interfaces/api/order/OrderApiE2ETest.java | 347 ++++++++++++++++++ 21 files changed, 1200 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java new file mode 100644 index 000000000..b8da92b8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java @@ -0,0 +1,28 @@ +package com.loopers.application.order; + +import java.math.BigDecimal; +import java.util.List; + +public record OrderCommand() { + + public record Create( + Long userId, + List items + ) { + public static Create of(Long userId, List items) { + return new Create(userId, items); + } + } + + public record CreateItem( + Long productId, + String productName, + BigDecimal price, + int quantity + ) { + public static CreateItem of(Long productId, String productName, + BigDecimal price, int quantity) { + return new CreateItem(productId, productName, price, quantity); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..7f48cd2f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,68 @@ +package com.loopers.application.order; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.order.Order; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@Validated +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + + // Command + + @Transactional + public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { + var items = request.orderItems(); + + Set productIds = items.stream() + .map(OrderRequest.PlaceItem::productId) + .collect(Collectors.toSet()); + + if (productIds.size() != items.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품이 중복되었습니다"); + } + + Map productQuantities = items.stream() + .collect(Collectors.toMap( + OrderRequest.PlaceItem::productId, + OrderRequest.PlaceItem::quantity + )); + List products = productService.deductStocks(productQuantities); + + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + List orderItems = items.stream() + .map(item -> { + Product product = productMap.get(item.productId()); + return OrderCommand.CreateItem.of( + product.getId(), + product.getName(), + product.getPrice(), + item.quantity() + ); + }) + .toList(); + + Order order = orderService.createOrder(OrderCommand.Create.of(userId, orderItems)); + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..5ecc25e53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,47 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long id, + BigDecimal totalAmount, + List orderItems, + ZonedDateTime createdAt +) { + + public record OrderItemInfo( + Long productId, + String productName, + BigDecimal price, + Integer quantity, + BigDecimal orderPrice + ) { + + public static OrderItemInfo from(OrderItem orderItem) { + return new OrderItemInfo( + orderItem.getProductId(), + orderItem.getProductName(), + orderItem.getPrice(), + orderItem.getQuantity(), + orderItem.getOrderPrice() + ); + } + } + + public static OrderInfo from(Order order) { + List items = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), + order.getTotalAmount(), + items, + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java new file mode 100644 index 000000000..0a702314d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -0,0 +1,32 @@ +package com.loopers.application.order; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record OrderRequest() { + + // Command + + public record Place( + @NotNull(message = "주문 상품 목록은 필수입니다") + @Size(min = 1, max = 100, message = "주문 상품은 1~100건이어야 합니다") + List<@Valid PlaceItem> orderItems + ) { + } + + public record PlaceItem( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + + @NotNull(message = "수량은 필수입니다") + @Min(value = 1, message = "수량은 1 이상이어야 합니다") + @Max(value = 9999999, message = "수량은 9,999,999 이하여야 합니다") + Integer quantity + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..527790a8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,33 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderService { + + private final OrderRepository orderRepository; + + // Command + + @Transactional + public Order createOrder(OrderCommand.Create command) { + Order order = Order.create(command.userId()); + + command.items().forEach(item -> + order.addItem( + item.productId(), + item.productName(), + item.price(), + item.quantity() + ) + ); + + return orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 3b927cc84..3ad96b77b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -14,6 +14,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -104,7 +107,12 @@ public Page findProducts(String name, Long brandId, Boolean deleted, Pa return productRepository.findAll(name, brandId, deleted, pageable); } - public List getProducts(List productIds) { - return productRepository.findAllByIdIn(productIds); + public Page findActiveProducts(Long brandId, Pageable pageable) { + return productRepository.findAllActive(brandId, pageable); + } + + public Map getProductsMapByIds(Set productIds) { + return productRepository.findAllByIdIn(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..a20a64e68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,90 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +public class Order { + + private static final int ORDER_ITEMS_MAX_SIZE = 100; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal totalAmount; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) + private List orderItems = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Order() { + } + + public static Order create(Long userId) { + validateUserId(userId); + Order order = new Order(); + order.userId = userId; + order.totalAmount = BigDecimal.ZERO; + return order; + } + + public void addItem(Long productId, String productName, + BigDecimal price, int quantity) { + validateMaxSize(); + validateDuplicateProduct(productId); + + OrderItem item = OrderItem.create(productId, productName, price, quantity); + item.assignOrder(this); + this.orderItems.add(item); + this.totalAmount = this.totalAmount.add(item.getOrderPrice()); + } + + @PrePersist + protected void onCreate() { + this.createdAt = ZonedDateTime.now(); + } + + private void validateMaxSize() { + if (orderItems.size() >= ORDER_ITEMS_MAX_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품은 100개 이하여야 합니다"); + } + } + + private void validateDuplicateProduct(Long productId) { + boolean exists = orderItems.stream() + .anyMatch(item -> item.getProductId().equals(productId)); + if (exists) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품이 중복되었습니다"); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..7f20e87d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,115 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "order_item") +@Getter +public class OrderItem { + + private static final int QUANTITY_MIN = 1; + private static final int QUANTITY_MAX = 9_999_999; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + @Column(nullable = false) + private Integer quantity; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected OrderItem() { + } + + private OrderItem(Long productId, String productName, BigDecimal price, Integer quantity) { + this.productId = productId; + this.productName = productName; + this.price = price; + this.quantity = quantity; + } + + public static OrderItem create(Long productId, String productName, + BigDecimal price, Integer quantity) { + validateProductId(productId); + validateProductName(productName); + validatePrice(price); + validateQuantity(quantity); + return new OrderItem(productId, productName, price, quantity); + } + + void assignOrder(Order order) { + this.order = order; + } + + public BigDecimal getOrderPrice() { + return price.multiply(BigDecimal.valueOf(quantity)); + } + + @PrePersist + protected void onCreate() { + this.createdAt = ZonedDateTime.now(); + } + + private static void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다"); + } + } + + private static void validateProductName(String productName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다"); + } + } + + private static void validatePrice(BigDecimal price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 필수입니다"); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다"); + } + } + + private static void validateQuantity(Integer quantity) { + if (quantity == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 필수입니다"); + } + if (quantity < QUANTITY_MIN) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다"); + } + if (quantity > QUANTITY_MAX) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 9,999,999 이하여야 합니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..2dfaa1866 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + + // Command + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..5843c14e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + // Command + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 881236b92..be8b0f355 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -10,12 +10,13 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.Lock; +import java.util.Collection; import java.util.List; public interface ProductJpaRepository extends JpaRepository { // Query - List findAllByIdIn(List ids); + List findAllByIdIn(Collection ids); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id IN :ids") @@ -34,4 +35,12 @@ public interface ProductJpaRepository extends JpaRepository { + "AND (:brandId IS NULL OR p.brandId = :brandId) " + "AND (:deleted IS NULL OR (:deleted = true AND p.deletedAt IS NOT NULL) OR (:deleted = false AND p.deletedAt IS NULL))") Page findAll(@Param("name") String name, @Param("brandId") Long brandId, @Param("deleted") Boolean deleted, Pageable pageable); + + @Query(value = "SELECT p FROM Product p " + + "WHERE p.deletedAt IS NULL " + + "AND (:brandId IS NULL OR p.brandId = :brandId)", + countQuery = "SELECT COUNT(p) FROM Product p " + + "WHERE p.deletedAt IS NULL " + + "AND (:brandId IS NULL OR p.brandId = :brandId)") + Page findAllActive(@Param("brandId") Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 99ea0a18a..3aa939a9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -24,7 +25,7 @@ public Product save(Product product) { // Query @Override - public List findAllByIdIn(List ids) { + public List findAllByIdIn(Collection ids) { return productJpaRepository.findAllByIdIn(ids); } @@ -47,4 +48,9 @@ public List findAllByBrandId(Long brandId) { public Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable) { return productJpaRepository.findAll(name, brandId, deleted, pageable); } + + @Override + public Page findAllActive(Long brandId, Pageable pageable) { + return productJpaRepository.findAllActive(brandId, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index cce96ce26..ff96d8ad4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -39,6 +40,15 @@ public ResponseEntity> handleBadRequest(MethodArgumentNotValidExc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(violation -> violation.getMessage()) + .findFirst() + .orElse("잘못된 요청입니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { String name = e.getName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java new file mode 100644 index 000000000..089b5a10c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order API", description = "주문 API") +public interface OrderApiV1Spec { + + // Command + + @Operation( + summary = "주문 요청", + description = "여러 상품을 한 번에 주문합니다. 재고 확인 및 차감 후 주문을 생성합니다." + ) + ApiResponse createOrder( + AuthenticatedUser user, + OrderRequest.Place request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..b4d790ddc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +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/v1/orders") +@RequiredArgsConstructor +public class OrderV1Controller implements OrderApiV1Spec { + + private final OrderFacade orderFacade; + + // Command + + @PostMapping + @Override + public ApiResponse createOrder( + @AuthUser AuthenticatedUser user, + @RequestBody OrderRequest.Place request) { + OrderInfo info = orderFacade.createOrder(user.id(), request); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..396278862 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + // Response + + public record OrderResponse( + Long id, + BigDecimal totalAmount, + List orderItems, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderInfo info) { + List items = info.orderItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.id(), + info.totalAmount(), + items, + info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + BigDecimal price, + Integer quantity, + BigDecimal orderPrice + ) { + + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.price(), + item.quantity(), + item.orderPrice() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..f9d6ffbb8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -0,0 +1,76 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderServiceIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 주문_생성 { + + @Test + void 주문_시점의_상품_정보를_스냅샷으로_저장한다() { + OrderCommand.Create command = OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 2) + )); + + Order order = orderService.createOrder(command); + + assertThat(order.getId()).isNotNull(); + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getOrderItems().get(0).getProductName()).isEqualTo("운동화"); + assertThat(order.getOrderItems().get(0).getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + } + + @Test + void 원본_상품이_수정되어도_주문_스냅샷은_영향받지_않는다() { + Product product = productRepository.save( + Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화") + ); + OrderCommand.Create command = OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(product.getId(), "운동화", new BigDecimal("50000"), 2) + )); + Order order = orderService.createOrder(command); + + product.update("런닝화", new BigDecimal("70000"), null, null); + productRepository.save(product); + + Product updatedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getName()).isEqualTo("런닝화"); + assertThat(updatedProduct.getPrice()).isEqualByComparingTo(new BigDecimal("70000")); + + assertThat(order.getOrderItems().get(0).getProductName()).isEqualTo("운동화"); + assertThat(order.getOrderItems().get(0).getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..da90866a4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,105 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderItemTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_주문상품이_생성된다() { + OrderItem orderItem = OrderItem.create(1L, "운동화", new BigDecimal("50000"), 2); + + assertThat(orderItem.getProductId()).isEqualTo(1L); + assertThat(orderItem.getProductName()).isEqualTo("운동화"); + assertThat(orderItem.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); + assertThat(orderItem.getQuantity()).isEqualTo(2); + } + + @Test + void 상품ID가_null이면_예외() { + assertThatThrownBy(() -> OrderItem.create(null, "운동화", new BigDecimal("50000"), 2)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품 ID는 필수입니다"); + } + + @Test + void 상품명이_null이면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, null, new BigDecimal("50000"), 2)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품명은 필수입니다"); + } + + @Test + void 가격이_null이면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, "운동화", null, 2)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 필수입니다"); + } + + @Test + void 가격이_음수면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, "운동화", new BigDecimal("-1"), 2)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("가격은 0 이상이어야 합니다"); + } + + @Test + void 수량이_null이면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, "운동화", new BigDecimal("50000"), null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("수량은 필수입니다"); + } + + @Test + void 수량이_0이면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, "운동화", new BigDecimal("50000"), 0)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("수량은 1 이상이어야 합니다"); + } + + @Test + void 수량이_최대값을_초과하면_예외() { + assertThatThrownBy(() -> OrderItem.create(1L, "운동화", new BigDecimal("50000"), 10_000_000)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("수량은 9,999,999 이하여야 합니다"); + } + } + + @Nested + class 주문금액_계산 { + + @Test + void 주문금액은_가격_곱하기_수량이다() { + OrderItem orderItem = OrderItem.create(1L, "운동화", new BigDecimal("50000"), 3); + + assertThat(orderItem.getOrderPrice()).isEqualByComparingTo(new BigDecimal("150000")); + } + + @Test + void 수량이_1이면_주문금액은_가격과_같다() { + OrderItem orderItem = OrderItem.create(1L, "운동화", new BigDecimal("50000"), 1); + + assertThat(orderItem.getOrderPrice()).isEqualByComparingTo(new BigDecimal("50000")); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b305ed213 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,84 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_주문이_생성된다() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 2); + order.addItem(2L, "셔츠", new BigDecimal("30000"), 1); + + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderItems()).hasSize(2); + } + + @Test + void 사용자ID가_null이면_예외() { + assertThatThrownBy(() -> Order.create(null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("사용자 ID는 필수입니다"); + } + + @Test + void 주문상품이_100개를_초과하면_예외() { + Order order = Order.create(1L); + IntStream.rangeClosed(1, 100) + .forEach(i -> order.addItem((long) i, "상품" + i, new BigDecimal("1000"), 1)); + + assertThatThrownBy(() -> order.addItem(101L, "상품101", new BigDecimal("1000"), 1)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("주문 상품은 100개 이하여야 합니다"); + } + + @Test + void 동일_상품ID가_중복되면_예외() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 2); + + assertThatThrownBy(() -> order.addItem(1L, "운동화", new BigDecimal("50000"), 3)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("주문 상품이 중복되었습니다"); + } + } + + @Nested + class 총_주문금액_계산 { + + @Test + void 총_주문금액은_각_주문상품의_주문금액_합계이다() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 2); // 100,000 + order.addItem(2L, "셔츠", new BigDecimal("30000"), 1); // 30,000 + + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("130000")); + } + + @Test + void 상품이_하나면_해당_상품의_주문금액이_총_주문금액이다() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 3); // 150,000 + + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("150000")); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java new file mode 100644 index 000000000..5f64e1068 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -0,0 +1,347 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderApiE2ETest { + + private static final String ENDPOINT = "/api/v1/orders"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String USER_ENDPOINT = "/api/v1/users"; + private static final String VALID_LDAP = "admin-ldap"; + private static final String USER_LOGIN_ID = "testuser"; + private static final String USER_PASSWORD = "Test1234!"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 주문_요청 { + + @Test + void 유효한_정보로_주문하면_200_응답과_생성된_주문_정보를_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId1, 2), + new OrderRequest.PlaceItem(productId2, 1) + )); + + ResponseEntity> response = postOrder(request); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().totalAmount()).isEqualByComparingTo(new BigDecimal("130000")), + () -> assertThat(response.getBody().data().orderItems()).hasSize(2), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @Test + void 주문_시_스냅샷_정보가_올바르게_저장된다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId, 3) + )); + + ResponseEntity> response = postOrder(request); + + OrderV1Dto.OrderItemResponse item = response.getBody().data().orderItems().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo("운동화"), + () -> assertThat(item.price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(item.quantity()).isEqualTo(3), + () -> assertThat(item.orderPrice()).isEqualByComparingTo(new BigDecimal("150000")) + ); + } + + @Test + void 주문_시_해당_상품의_재고가_주문_수량만큼_차감된다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId, 3) + )); + + ResponseEntity> response = postOrder(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + List products = getProductList(); + ProductAdminV1Dto.ProductResponse product = products.stream() + .filter(p -> p.id().equals(productId)) + .findFirst() + .orElseThrow(); + + assertThat(product.stockQuantity()).isEqualTo(97); + } + + @Test + void 미존재_상품이_포함되면_404_응답() { + signUp(); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(999L, 1) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품이 포함되어 있습니다") + ); + } + + @Test + void 재고가_부족하면_400_응답() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 5, "편한 운동화"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId, 10) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().message()).contains("재고가 부족한 상품이 있습니다") + ); + } + + @Test + void 재고_부족_시_전체_주문이_실패하며_어떤_상품의_재고도_차감되지_않는다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 3, "멋진 셔츠"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId1, 2), + new OrderRequest.PlaceItem(productId2, 10) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + List products = getProductList(); + ProductAdminV1Dto.ProductResponse product1 = products.stream() + .filter(p -> p.id().equals(productId1)) + .findFirst() + .orElseThrow(); + ProductAdminV1Dto.ProductResponse product2 = products.stream() + .filter(p -> p.id().equals(productId2)) + .findFirst() + .orElseThrow(); + + assertAll( + () -> assertThat(product1.stockQuantity()).isEqualTo(100), + () -> assertThat(product2.stockQuantity()).isEqualTo(3) + ); + } + + @Test + void 동일_상품ID가_중복으로_포함되면_400_응답() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId, 1), + new OrderRequest.PlaceItem(productId, 2) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().message()).contains("주문 상품이 중복되었습니다") + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + signUp(); + + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(1L, 0) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(1L, 1) + )); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + OrderRequest.Place request = new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(1L, 1) + )); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // --- 헬퍼 메서드 --- + + private void signUp() { + UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + USER_LOGIN_ID, USER_PASSWORD, "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com" + ); + testRestTemplate.exchange( + USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private List getProductList() { + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().content(); + } + + private ResponseEntity> postOrder(OrderRequest.Place request) { + return testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } + + private HttpHeaders userHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", USER_LOGIN_ID); + headers.set("X-Loopers-LoginPw", USER_PASSWORD); + return headers; + } +} From 32e891e35869446c99b91b07acfda80905252699 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:21:06 +0900 Subject: [PATCH 37/68] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20DTO=EB=A5=BC=20Request/Command=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 15 +++++--- .../loopers/application/user/UserRequest.java | 34 +++++++++++++++++ .../interfaces/api/user/UserApiV1Spec.java | 5 ++- .../interfaces/api/user/UserV1Controller.java | 16 +++----- .../interfaces/api/user/UserV1Dto.java | 25 ------------ .../interfaces/api/user/UserApiE2ETest.java | 38 +++++++------------ 6 files changed, 64 insertions(+), 69 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 81a7ca941..9d1af7d15 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,13 +1,14 @@ package com.loopers.application.user; import com.loopers.domain.user.User; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; +import org.springframework.validation.annotation.Validated; @Component +@Validated @RequiredArgsConstructor @Transactional(readOnly = true) public class UserFacade { @@ -17,14 +18,16 @@ public class UserFacade { // Command @Transactional - public UserInfo signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { - User user = userService.signUp(loginId, rawPassword, name, birthDate, email); + public UserInfo signUp(@Valid UserRequest.SignUp request) { + User user = userService.signUp( + request.loginId(), request.password(), request.name(), + request.birthDate(), request.email()); return UserInfo.from(user); } @Transactional - public void changePassword(Long id, String newRawPassword) { - userService.changePassword(id, newRawPassword); + public void changePassword(Long id, @Valid UserRequest.ChangePassword request) { + userService.changePassword(id, request.newPassword()); } // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java new file mode 100644 index 000000000..8c2000556 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java @@ -0,0 +1,34 @@ +package com.loopers.application.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public record UserRequest() { + + // Command + + public record SignUp( + @NotBlank(message = "로그인 ID는 필수입니다") + @Size(min = 4, max = 20, message = "로그인 ID는 4~20자여야 합니다") + String loginId, + @NotBlank(message = "비밀번호는 필수입니다") + String password, + @NotBlank(message = "이름은 필수입니다") + @Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다") + String name, + @NotNull(message = "생년월일은 필수입니다") + LocalDate birthDate, + @NotBlank(message = "이메일은 필수입니다") + String email + ) { + } + + public record ChangePassword( + @NotBlank(message = "새 비밀번호는 필수입니다") + String newPassword + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java index 9452080cd..711856cc8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; @@ -15,7 +16,7 @@ public interface UserApiV1Spec { summary = "회원가입", description = "새로운 회원을 등록합니다. 이름은 마지막 글자가 마스킹되어 반환됩니다." ) - ApiResponse signUp(UserV1Dto.SignUpRequest request); + ApiResponse signUp(UserRequest.SignUp request); @Operation( summary = "비밀번호 변경", @@ -23,7 +24,7 @@ public interface UserApiV1Spec { ) ApiResponse changePassword( @Parameter(hidden = true) AuthenticatedUser authUser, - UserV1Dto.ChangePasswordRequest request + UserRequest.ChangePassword request ); // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 21d47b7cb..964cdac67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,10 +2,10 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -25,14 +25,8 @@ public class UserV1Controller implements UserApiV1Spec { @PostMapping @Override - public ApiResponse signUp(@Valid @RequestBody UserV1Dto.SignUpRequest request) { - UserInfo info = userFacade.signUp( - request.loginId(), - request.password(), - request.name(), - request.birthDate(), - request.email() - ); + public ApiResponse signUp(@RequestBody UserRequest.SignUp request) { + UserInfo info = userFacade.signUp(request); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } @@ -40,8 +34,8 @@ public ApiResponse signUp(@Valid @RequestBody UserV1Dto. @Override public ApiResponse changePassword( @AuthUser AuthenticatedUser authUser, - @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { - userFacade.changePassword(authUser.id(), request.newPassword()); + @RequestBody UserRequest.ChangePassword request) { + userFacade.changePassword(authUser.id(), request); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 820de6423..583189912 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,36 +1,11 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.UserInfo; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import java.time.LocalDate; public class UserV1Dto { - // Command - - public record SignUpRequest( - @NotBlank(message = "로그인 ID는 필수입니다") - @Size(min = 4, max = 20, message = "로그인 ID는 4~20자여야 합니다") - String loginId, - @NotBlank(message = "비밀번호는 필수입니다") - String password, - @NotBlank(message = "이름은 필수입니다") - @Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다") - String name, - @NotNull(message = "생년월일은 필수입니다") - LocalDate birthDate, - @NotBlank(message = "이메일은 필수입니다") - String email - ) {} - - public record ChangePasswordRequest( - @NotBlank(message = "새 비밀번호는 필수입니다") - String newPassword - ) {} - // Response public record UserResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 04029caa4..a1eddd523 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -46,7 +47,7 @@ class 회원가입 { @Test void 유효한_정보로_회원가입하면_회원정보가_반환된다() { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + UserRequest.SignUp request = new UserRequest.SignUp( "testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com" ); @@ -64,27 +65,24 @@ class 회원가입 { @Test void 이미_존재하는_로그인ID로_가입하면_409_응답() { - // arrange signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.SignUpRequest duplicateRequest = new UserV1Dto.SignUpRequest( + UserRequest.SignUp duplicateRequest = new UserRequest.SignUp( "testuser", "Test5678!", "김철수", LocalDate.of(1995, 5, 20), "other@example.com" ); - // act ResponseEntity> response = testRestTemplate.exchange( SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(duplicateRequest), new ParameterizedTypeReference<>() {} ); - // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } @Test void 유효하지_않은_입력이면_400_응답() { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + UserRequest.SignUp request = new UserRequest.SignUp( "test-user!", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com" ); @@ -99,7 +97,7 @@ class 회원가입 { @Test void 비밀번호에_생년월일이_포함되면_400_응답() { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + UserRequest.SignUp request = new UserRequest.SignUp( "testuser", "Abcd20000115!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com" ); @@ -172,65 +170,55 @@ class 비밀번호_변경 { @Test void 유효한_새_비밀번호로_변경하면_새_비밀번호로_인증할_수_있다() { - // arrange signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("NewPass123!"); - // act ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); - // assert - 변경 성공 assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - // assert - 새 비밀번호로 인증 가능 ResponseEntity> verifyResponse = getMyInfo("testuser", "NewPass123!"); assertThat(verifyResponse.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test void 현재_비밀번호와_동일한_비밀번호로_변경하면_400_응답() { - // arrange signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("Test1234!"); - // act ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); - // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test void 인증_실패하면_401_응답() { - // arrange signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("NewPass123!"); - // act ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, authHeaders("testuser", "WrongPass1!")), new ParameterizedTypeReference<>() {} ); - // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void 인증헤더가_누락되면_401_응답() { - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("NewPass123!"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("NewPass123!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, @@ -245,7 +233,7 @@ class 비밀번호_변경 { void 비밀번호에_생년월일이_포함되면_400_응답() { signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Abcd20000115!"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("Abcd20000115!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, @@ -260,7 +248,7 @@ class 비밀번호_변경 { void 유효하지_않은_비밀번호면_400_응답() { signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("short"); + UserRequest.ChangePassword request = new UserRequest.ChangePassword("short"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, @@ -275,11 +263,11 @@ class 비밀번호_변경 { // --- 헬퍼 메서드 --- private void signUp(String loginId, String password, String name, LocalDate birthDate, String email) { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest(loginId, password, name, birthDate, email); + UserRequest.SignUp request = new UserRequest.SignUp(loginId, password, name, birthDate, email); postSignUp(request); } - private ResponseEntity> postSignUp(UserV1Dto.SignUpRequest request) { + private ResponseEntity> postSignUp(UserRequest.SignUp request) { return testRestTemplate.exchange( SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() {} From 8e5b3402b4cc07fdb75793ca18939bb2d37715e0 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:22:16 +0900 Subject: [PATCH 38/68] =?UTF-8?q?refactor:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20DTO=EB=A5=BC=20Request/Command=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 20 +++-- .../application/brand/BrandRequest.java | 88 +++++++++++++++++++ .../application/brand/BrandService.java | 9 ++ .../loopers/domain/brand/BrandRepository.java | 3 +- .../brand/BrandJpaRepository.java | 5 ++ .../brand/BrandRepositoryImpl.java | 5 +- .../api/brand/BrandAdminApiV1Spec.java | 7 +- .../api/brand/BrandAdminV1Controller.java | 14 +-- .../interfaces/api/brand/BrandAdminV1Dto.java | 61 ------------- .../interfaces/api/brand/BrandApiV1Spec.java | 3 +- .../api/brand/BrandV1Controller.java | 6 +- .../interfaces/api/brand/BrandV1Dto.java | 27 ------ .../api/brand/BrandAdminApiE2ETest.java | 46 +++++----- .../interfaces/api/brand/BrandApiE2ETest.java | 5 +- 14 files changed, 162 insertions(+), 137 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index cb4b6772f..7d0b82fbd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -2,13 +2,15 @@ import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; @Component +@Validated @RequiredArgsConstructor @Transactional(readOnly = true) public class BrandFacade { @@ -19,14 +21,14 @@ public class BrandFacade { // Command @Transactional - public BrandInfo register(String name, String description) { - Brand brand = brandService.register(name, description); + public BrandInfo register(@Valid BrandRequest.Register request) { + Brand brand = brandService.register(request.name(), request.description()); return BrandInfo.from(brand); } @Transactional - public BrandInfo update(Long brandId, String name, String description) { - Brand brand = brandService.update(brandId, name, description); + public BrandInfo update(Long brandId, @Valid BrandRequest.Update request) { + Brand brand = brandService.update(brandId, request.name(), request.description()); return BrandInfo.from(brand); } @@ -38,8 +40,8 @@ public void delete(Long brandId) { // Query - public Page getList(String name, Boolean deleted, Pageable pageable) { - Page brands = brandService.findBrands(name, deleted, pageable); + public Page getList(@Valid BrandRequest.ListAll request) { + Page brands = brandService.findBrands(request.name(), request.toDeleted(), request.toPageable()); return brands.map(BrandInfo::from); } @@ -48,8 +50,8 @@ public BrandInfo getDetail(Long brandId) { return BrandInfo.from(brand); } - public Page getActiveList(String name, Pageable pageable) { - Page brands = brandService.findActiveBrands(name, pageable); + public Page getActiveList(@Valid BrandRequest.ListActive request) { + Page brands = brandService.findActiveBrands(request.name(), request.toPageable()); return brands.map(BrandInfo::from); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java new file mode 100644 index 000000000..f53e12725 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java @@ -0,0 +1,88 @@ +package com.loopers.application.brand; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +public record BrandRequest() { + + // Command + + public record Register( + @NotBlank(message = "브랜드명은 필수입니다") + @Size(max = 100, message = "브랜드명은 100자 이하여야 합니다") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") + String description + ) { + } + + public record Update( + @Size(min = 1, max = 100, message = "브랜드명은 1~100자여야 합니다") + String name, + + @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") + String description + ) { + } + + // Query + + public record ListAll( + String name, + BrandStatus status, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListAll { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public enum BrandStatus { + ACTIVE, DELETED; + + public Boolean toDeleted() { + return this == DELETED ? Boolean.TRUE : Boolean.FALSE; + } + } + + public Boolean toDeleted() { + return status != null ? status.toDeleted() : null; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + + public record ListActive( + String name, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListActive { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 5ff320280..7f2f597a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -11,6 +11,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -76,4 +80,9 @@ public List getBrands(List brandIds) { public Page findActiveBrands(String name, Pageable pageable) { return brandRepository.findAllActive(name, pageable); } + + public Map getBrandsMapByIds(Set brandIds) { + return brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 0b22a95b6..ff2ff7ff9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -22,7 +23,7 @@ public interface BrandRepository { Page findAll(String name, Boolean deleted, Pageable pageable); - List findAllByIdIn(List ids); + List findAllByIdIn(Collection ids); Page findAllActive(String name, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index a56f14992..fb299caba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; + public interface BrandJpaRepository extends JpaRepository { // Query @@ -32,4 +35,6 @@ public interface BrandJpaRepository extends JpaRepository { + "WHERE b.deletedAt IS NULL " + "AND (:name IS NULL OR b.name LIKE %:name%)") Page findAllActive(@Param("name") String name, Pageable pageable); + + List findAllByIdIn(Collection ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 4d2a11830..af0477ca3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -41,8 +42,8 @@ public boolean existsByNameAndIdNot(String name, Long id) { } @Override - public List findAllByIdIn(List ids) { - return brandJpaRepository.findAllById(ids); + public List findAllByIdIn(Collection ids) { + return brandJpaRepository.findAllByIdIn(ids); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index b7af12426..bd6cacd12 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; @@ -15,7 +16,7 @@ public interface BrandAdminApiV1Spec { description = "새로운 입점 브랜드를 등록합니다." ) ApiResponse register( - BrandAdminV1Dto.RegisterRequest request + BrandRequest.Register request ); @Operation( @@ -24,7 +25,7 @@ ApiResponse register( ) ApiResponse update( Long brandId, - BrandAdminV1Dto.UpdateRequest request + BrandRequest.Update request ); @Operation( @@ -40,7 +41,7 @@ ApiResponse update( description = "브랜드 목록을 검색 조건과 함께 페이징 조회합니다." ) ApiResponse> list( - BrandAdminV1Dto.ListRequest request + BrandRequest.ListAll request ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 3ed0c5409..e4111586e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -28,8 +28,8 @@ public class BrandAdminV1Controller implements BrandAdminApiV1Spec { @PostMapping @Override public ApiResponse register( - @Valid @RequestBody BrandAdminV1Dto.RegisterRequest request) { - BrandInfo info = brandFacade.register(request.name(), request.description()); + @RequestBody BrandRequest.Register request) { + BrandInfo info = brandFacade.register(request); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @@ -37,8 +37,8 @@ public ApiResponse register( @Override public ApiResponse update( @PathVariable Long brandId, - @Valid @RequestBody BrandAdminV1Dto.UpdateRequest request) { - BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + @RequestBody BrandRequest.Update request) { + BrandInfo info = brandFacade.update(brandId, request); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @@ -54,8 +54,8 @@ public ApiResponse delete(@PathVariable Long brandId) { @GetMapping @Override public ApiResponse> list( - @Valid BrandAdminV1Dto.ListRequest request) { - Page brands = brandFacade.getList(request.name(), request.toDeleted(), request.toPageable()); + BrandRequest.ListAll request) { + Page brands = brandFacade.getList(request); PageResponse pageResponse = PageResponse.from(brands, BrandAdminV1Dto.BrandResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java index 6f5fda60a..2517c057a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -1,72 +1,11 @@ package com.loopers.interfaces.api.brand; import com.loopers.application.brand.BrandInfo; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.PositiveOrZero; -import jakarta.validation.constraints.Size; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; public class BrandAdminV1Dto { - // Command - - public record RegisterRequest( - @NotBlank(message = "브랜드명은 필수입니다") - @Size(max = 100, message = "브랜드명은 100자 이하여야 합니다") - String name, - - @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") - String description - ) {} - - public record UpdateRequest( - @Size(min = 1, max = 100, message = "브랜드명은 1~100자여야 합니다") - String name, - - @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") - String description - ) {} - - // Query - - public record ListRequest( - String name, - BrandStatus status, - - @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") - Integer page, - - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") - @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") - Integer size - ) { - public ListRequest { - if (page == null) page = 0; - if (size == null) size = 20; - } - - public enum BrandStatus { - ACTIVE, DELETED; - - public Boolean toDeleted() { - return this == DELETED ? Boolean.TRUE : Boolean.FALSE; - } - } - - public Boolean toDeleted() { - return status != null ? status.toDeleted() : null; - } - - public Pageable toPageable() { - return PageRequest.of(page, size); - } - } - // Response public record BrandResponse( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java index e578b1eff..9de35e3c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; @@ -15,7 +16,7 @@ public interface BrandApiV1Spec { description = "활성 브랜드 목록을 이름 오름차순으로 페이징 조회합니다." ) ApiResponse> list( - BrandV1Dto.ListRequest request + BrandRequest.ListActive request ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index 6af7a0247..3dc47a0fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.GetMapping; @@ -24,8 +24,8 @@ public class BrandV1Controller implements BrandApiV1Spec { @GetMapping @Override public ApiResponse> list( - @Valid BrandV1Dto.ListRequest request) { - Page brands = brandFacade.getActiveList(request.name(), request.toPageable()); + BrandRequest.ListActive request) { + Page brands = brandFacade.getActiveList(request); PageResponse pageResponse = PageResponse.from(brands, BrandV1Dto.BrandResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java index 4a2411937..df958845f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -1,38 +1,11 @@ package com.loopers.interfaces.api.brand; import com.loopers.application.brand.BrandInfo; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.PositiveOrZero; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; public class BrandV1Dto { - // Query - - public record ListRequest( - String name, - - @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") - Integer page, - - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") - @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") - Integer size - ) { - public ListRequest { - if (page == null) page = 0; - if (size == null) size = 20; - } - - public Pageable toPageable() { - return PageRequest.of(page, size); - } - } - // Response public record BrandResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 24b8e1415..fc8071006 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.ProductAdminV1Dto; @@ -48,7 +50,7 @@ class 브랜드_등록 { @Test void 유효한_정보로_등록하면_브랜드_정보가_반환된다() { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + BrandRequest.Register request = new BrandRequest.Register("나이키", "스포츠 브랜드"); ResponseEntity> response = postRegister(request); @@ -65,9 +67,9 @@ class 브랜드_등록 { @Test void 이미_존재하는_브랜드명이면_409_응답() { - postRegister(new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드")); + postRegister(new BrandRequest.Register("나이키", "스포츠 브랜드")); - BrandAdminV1Dto.RegisterRequest duplicateRequest = new BrandAdminV1Dto.RegisterRequest("나이키", "다른 설명"); + BrandRequest.Register duplicateRequest = new BrandRequest.Register("나이키", "다른 설명"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, @@ -80,7 +82,7 @@ class 브랜드_등록 { @Test void 브랜드명이_빈값이면_400_응답() { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("", "설명"); + BrandRequest.Register request = new BrandRequest.Register("", "설명"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, @@ -93,7 +95,7 @@ class 브랜드_등록 { @Test void 인증헤더가_누락되면_401_응답() { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + BrandRequest.Register request = new BrandRequest.Register("나이키", "스포츠 브랜드"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, @@ -106,7 +108,7 @@ class 브랜드_등록 { @Test void 인증에_실패하면_401_응답() { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest("나이키", "스포츠 브랜드"); + BrandRequest.Register request = new BrandRequest.Register("나이키", "스포츠 브랜드"); HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", "wrong-ldap"); @@ -127,7 +129,7 @@ class 브랜드_등록 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(new BrandAdminV1Dto.RegisterRequest("나이키", "다른 설명"), adminHeaders()), + new HttpEntity<>(new BrandRequest.Register("나이키", "다른 설명"), adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -141,7 +143,7 @@ class 브랜드_수정 { @Test void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("아디다스", "독일 스포츠 브랜드"); + BrandRequest.Update request = new BrandRequest.Update("아디다스", "독일 스포츠 브랜드"); ResponseEntity> response = patchUpdate(brandId, request); @@ -155,7 +157,7 @@ class 브랜드_수정 { @Test void name만_보내면_name만_수정된다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("아디다스", null); + BrandRequest.Update request = new BrandRequest.Update("아디다스", null); ResponseEntity> response = patchUpdate(brandId, request); @@ -169,7 +171,7 @@ class 브랜드_수정 { @Test void description만_보내면_description만_수정된다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest(null, "변경된 설명"); + BrandRequest.Update request = new BrandRequest.Update(null, "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -184,7 +186,7 @@ class 브랜드_수정 { void 중복_브랜드명이면_409_응답() { registerBrand("나이키", "스포츠 브랜드"); Long adidasId = registerBrand("아디다스", "독일 스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + BrandRequest.Update request = new BrandRequest.Update("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, @@ -197,7 +199,7 @@ class 브랜드_수정 { @Test void 미존재_브랜드면_404_응답() { - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + BrandRequest.Update request = new BrandRequest.Update("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.PATCH, @@ -211,7 +213,7 @@ class 브랜드_수정 { @Test void 입력_규칙_위반_시_400_응답() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("", null); + BrandRequest.Update request = new BrandRequest.Update("", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, @@ -224,7 +226,7 @@ class 브랜드_수정 { @Test void 인증_누락이면_401_응답() { - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + BrandRequest.Update request = new BrandRequest.Update("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/1", HttpMethod.PATCH, @@ -237,7 +239,7 @@ class 브랜드_수정 { @Test void 인증_실패이면_401_응답() { - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", null); + BrandRequest.Update request = new BrandRequest.Update("나이키", null); HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", "wrong-ldap"); @@ -259,7 +261,7 @@ class 브랜드_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, - new HttpEntity<>(new BrandAdminV1Dto.UpdateRequest("나이키", null), adminHeaders()), + new HttpEntity<>(new BrandRequest.Update("나이키", null), adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -269,7 +271,7 @@ class 브랜드_수정 { @Test void 자기_자신의_현재_이름과_동일한_이름으로_수정하면_200_응답() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", "변경된 설명"); + BrandRequest.Update request = new BrandRequest.Update("나이키", "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -287,7 +289,7 @@ class 브랜드_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, - new HttpEntity<>(new BrandAdminV1Dto.UpdateRequest("변경이름", null), adminHeaders()), + new HttpEntity<>(new BrandRequest.Update("변경이름", null), adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -611,7 +613,7 @@ class 브랜드_상세_조회 { // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + BrandRequest.Register request = new BrandRequest.Register(name, description); ResponseEntity> response = postRegister(request); return response.getBody().data().id(); } @@ -620,7 +622,7 @@ private void deleteBrand(Long brandId) { deleteRequest(brandId); } - private ResponseEntity> postRegister(BrandAdminV1Dto.RegisterRequest request) { + private ResponseEntity> postRegister(BrandRequest.Register request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), @@ -628,7 +630,7 @@ private ResponseEntity> postRegister( ); } - private ResponseEntity> patchUpdate(Long brandId, BrandAdminV1Dto.UpdateRequest request) { + private ResponseEntity> patchUpdate(Long brandId, BrandRequest.Update request) { return testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, new HttpEntity<>(request, adminHeaders()), @@ -661,7 +663,7 @@ private ResponseEntity> getDetail(Lon } private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, name, price, stockQuantity, description ); ResponseEntity> response = testRestTemplate.exchange( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java index be90d2cb1..f998b238a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.utils.DatabaseCleanUp; @@ -72,6 +73,7 @@ class 브랜드_목록_조회 { ResponseEntity>> response = getList(""); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") ); @@ -87,6 +89,7 @@ class 브랜드_목록_조회 { getList("?name=나이키"); assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키") ); @@ -164,7 +167,7 @@ class 브랜드_상세_조회 { // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + BrandRequest.Register request = new BrandRequest.Register(name, description); ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), From 096cbca8e8c8a79ed564a46d0510fe74c7b5a3c4 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:23:18 +0900 Subject: [PATCH 39/68] =?UTF-8?q?refactor:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20DTO=EB=A5=BC=20Request/Command=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 50 ++- .../application/product/ProductRequest.java | 130 +++++++ .../domain/product/ProductRepository.java | 4 +- .../api/product/ProductAdminApiV1Spec.java | 7 +- .../api/product/ProductAdminV1Controller.java | 27 +- .../api/product/ProductAdminV1Dto.java | 88 ----- .../api/product/ProductUserApiV1Spec.java | 27 ++ .../api/product/ProductUserV1Controller.java | 40 ++ .../api/product/ProductUserV1Dto.java | 39 ++ .../ProductServiceIntegrationTest.java | 173 +++++++++ .../api/product/ProductAdminApiE2ETest.java | 38 +- .../api/product/ProductUserApiE2ETest.java | 362 ++++++++++++++++++ 12 files changed, 846 insertions(+), 139 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 099d121fa..57edfdf09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -5,19 +5,20 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.domain.product.Product; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; -import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @Component +@Validated @RequiredArgsConstructor @Transactional(readOnly = true) public class ProductFacade { @@ -28,15 +29,19 @@ public class ProductFacade { // Command @Transactional - public ProductInfo register(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - Brand brand = brandService.getActiveBrand(brandId); - Product product = productService.register(brandId, name, price, stockQuantity, description); + public ProductInfo register(@Valid ProductRequest.Register request) { + Brand brand = brandService.getActiveBrand(request.brandId()); + Product product = productService.register( + request.brandId(), request.name(), request.price(), + request.stockQuantity(), request.description()); return ProductInfo.from(product, brand.getName()); } @Transactional - public ProductInfo update(Long productId, String name, BigDecimal price, Integer stockQuantity, String description) { - Product product = productService.update(productId, name, price, stockQuantity, description); + public ProductInfo update(Long productId, @Valid ProductRequest.Update request) { + Product product = productService.update( + productId, request.name(), request.price(), + request.stockQuantity(), request.description()); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } @@ -54,8 +59,35 @@ public ProductInfo getDetail(Long productId) { return ProductInfo.from(product, brand.getName()); } - public Page getList(String name, Long brandId, Boolean deleted, Pageable pageable) { - Page products = productService.findProducts(name, brandId, deleted, pageable); + public ProductInfo getActiveDetail(Long productId) { + Product product = productService.getActiveProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand.getName()); + } + + public Page getActiveList(@Valid ProductRequest.ListActive request) { + Page products = productService.findActiveProducts(request.brandId(), request.toPageable()); + + List brandIds = products.getContent().stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return products.map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다"); + } + return ProductInfo.from(product, brand.getName()); + }); + } + + public Page getList(@Valid ProductRequest.ListAll request) { + Page products = productService.findProducts( + request.name(), request.brandId(), request.toDeleted(), request.toPageable()); List brandIds = products.getContent().stream() .map(Product::getBrandId) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java new file mode 100644 index 000000000..8484af1b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java @@ -0,0 +1,130 @@ +package com.loopers.application.product; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.math.BigDecimal; + +public record ProductRequest() { + + // Command + + public record Register( + @NotNull(message = "브랜드 ID는 필수입니다") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다") + @Size(max = 200, message = "상품명은 200자 이하여야 합니다") + String name, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") + @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") + BigDecimal price, + + @NotNull(message = "재고 수량은 필수입니다") + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") + Integer stockQuantity, + + @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") + String description + ) { + } + + public record Update( + @Size(min = 1, max = 200, message = "상품명은 1~200자여야 합니다") + String name, + + @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") + @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") + Integer stockQuantity, + + @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") + String description + ) { + } + + // Query + + public record ListAll( + String name, + Long brandId, + ProductStatus status, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListAll { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public enum ProductStatus { + ACTIVE, DELETED; + + public Boolean toDeleted() { + return this == DELETED ? Boolean.TRUE : Boolean.FALSE; + } + } + + public Boolean toDeleted() { + return status != null ? status.toDeleted() : null; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } + + public record ListActive( + Long brandId, + ProductSort sort, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListActive { + if (sort == null) sort = ProductSort.RECENT; + if (page == null) page = 0; + if (size == null) size = 20; + } + + public enum ProductSort { + RECENT, PRICE_ASC, LIKES_DESC; + + public Sort toSort() { + return switch (this) { + case RECENT -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + }; + } + } + + public Pageable toPageable() { + return PageRequest.of(page, size, sort.toSort()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 5e475c839..7eaa078f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -15,8 +16,9 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); - List findAllByIdIn(List ids); + List findAllByIdIn(Collection ids); List findAllByIdInForUpdate(List ids); Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable); + Page findAllActive(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index bf7ee9a70..e37829095 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; @@ -15,7 +16,7 @@ public interface ProductAdminApiV1Spec { description = "특정 브랜드에 속한 신규 상품을 등록합니다." ) ApiResponse register( - ProductAdminV1Dto.RegisterRequest request + ProductRequest.Register request ); @Operation( @@ -24,7 +25,7 @@ ApiResponse register( ) ApiResponse update( Long productId, - ProductAdminV1Dto.UpdateRequest request + ProductRequest.Update request ); @Operation( @@ -46,6 +47,6 @@ ApiResponse update( description = "전체 상품을 검색/필터링하여 페이징 조회합니다." ) ApiResponse> list( - ProductAdminV1Dto.ListRequest request + ProductRequest.ListAll request ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index f11766d85..7dfc13af1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -28,14 +28,8 @@ public class ProductAdminV1Controller implements ProductAdminApiV1Spec { @PostMapping @Override public ApiResponse register( - @Valid @RequestBody ProductAdminV1Dto.RegisterRequest request) { - ProductInfo info = productFacade.register( - request.brandId(), - request.name(), - request.price(), - request.stockQuantity(), - request.description() - ); + @RequestBody ProductRequest.Register request) { + ProductInfo info = productFacade.register(request); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } @@ -43,14 +37,8 @@ public ApiResponse register( @Override public ApiResponse update( @PathVariable Long productId, - @Valid @RequestBody ProductAdminV1Dto.UpdateRequest request) { - ProductInfo info = productFacade.update( - productId, - request.name(), - request.price(), - request.stockQuantity(), - request.description() - ); + @RequestBody ProductRequest.Update request) { + ProductInfo info = productFacade.update(productId, request); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } @@ -73,9 +61,8 @@ public ApiResponse detail(@PathVariable Long @GetMapping @Override public ApiResponse> list( - @Valid ProductAdminV1Dto.ListRequest request) { - Page products = productFacade.getList( - request.name(), request.brandId(), request.toDeleted(), request.toPageable()); + ProductRequest.ListAll request) { + Page products = productFacade.getList(request); PageResponse pageResponse = PageResponse.from(products, ProductAdminV1Dto.ProductResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index e922a3d26..afcc875f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -1,100 +1,12 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductInfo; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.PositiveOrZero; -import jakarta.validation.constraints.Size; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import java.math.BigDecimal; import java.time.LocalDateTime; public class ProductAdminV1Dto { - // Command - - public record RegisterRequest( - @NotNull(message = "브랜드 ID는 필수입니다") - Long brandId, - - @NotBlank(message = "상품명은 필수입니다") - @Size(max = 200, message = "상품명은 200자 이하여야 합니다") - String name, - - @NotNull(message = "가격은 필수입니다") - @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") - @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") - BigDecimal price, - - @NotNull(message = "재고 수량은 필수입니다") - @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") - @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") - Integer stockQuantity, - - @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") - String description - ) { - } - - public record UpdateRequest( - @Size(min = 1, max = 200, message = "상품명은 1~200자여야 합니다") - String name, - - @DecimalMin(value = "0", message = "가격은 0 이상이어야 합니다") - @DecimalMax(value = "999999999", message = "가격은 999,999,999 이하여야 합니다") - BigDecimal price, - - @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") - @Max(value = 9999999, message = "재고 수량은 9,999,999 이하여야 합니다") - Integer stockQuantity, - - @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") - String description - ) { - } - - // Query - - public record ListRequest( - String name, - Long brandId, - ProductStatus status, - - @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") - Integer page, - - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") - @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") - Integer size - ) { - public ListRequest { - if (page == null) page = 0; - if (size == null) size = 20; - } - - public enum ProductStatus { - ACTIVE, DELETED; - - public Boolean toDeleted() { - return this == DELETED ? Boolean.TRUE : Boolean.FALSE; - } - } - - public Boolean toDeleted() { - return status != null ? status.toDeleted() : null; - } - - public Pageable toPageable() { - return PageRequest.of(page, size); - } - } - // Response public record ProductResponse( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java new file mode 100644 index 000000000..90b0b0517 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product User API", description = "상품 사용자 API") +public interface ProductUserApiV1Spec { + + // Query + + @Operation( + summary = "상품 목록 조회", + description = "활성 상품을 정렬/필터링하여 페이징 조회합니다." + ) + ApiResponse> list( + ProductRequest.ListActive request + ); + + @Operation( + summary = "상품 상세 조회", + description = "활성 상품의 상세 정보를 조회합니다." + ) + ApiResponse detail(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java new file mode 100644 index 000000000..aece24de8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductUserV1Controller implements ProductUserApiV1Spec { + + private final ProductFacade productFacade; + + // Query + + @GetMapping + @Override + public ApiResponse> list( + ProductRequest.ListActive request) { + Page products = productFacade.getActiveList(request); + PageResponse pageResponse = + PageResponse.from(products, ProductUserV1Dto.ProductResponse::from); + return ApiResponse.success(pageResponse); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse detail(@PathVariable Long productId) { + ProductInfo info = productFacade.getActiveDetail(productId); + return ApiResponse.success(ProductUserV1Dto.ProductResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Dto.java new file mode 100644 index 000000000..16d7f6d07 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Dto.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class ProductUserV1Dto { + + // Response + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stockQuantity(), + info.description(), + info.likeCount(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 28d554a28..9ef6a0ffc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -16,8 +16,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import java.math.BigDecimal; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -238,6 +240,114 @@ class 상품_목록_조회 { } } + @Nested + class 활성_상품_조회 { + + @Test + void 활성_상품을_조회하면_성공한다() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + Product result = productService.getActiveProduct(product.getId()); + + assertThat(result.getId()).isEqualTo(product.getId()); + assertThat(result.getName()).isEqualTo("운동화"); + } + + @Test + void 삭제된_상품을_조회하면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + productRepository.save(product); + + assertThatThrownBy(() -> productService.getActiveProduct(product.getId())) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + + @Test + void 미존재_상품을_조회하면_예외() { + assertThatThrownBy(() -> productService.getActiveProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } + + @Nested + class 활성_상품_목록_조회 { + + @Test + void 조건_없이_조회하면_활성_상품만_최신순으로_반환한다() { + productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); + productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + Product deleted = productService.register(1L, "운동화C", new BigDecimal("30000"), 30, "설명C"); + deleted.delete(); + productRepository.save(deleted); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = productService.findActiveProducts(null, pageable); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting(Product::getName) + .containsExactly("운동화B", "운동화A"); + } + + @Test + void brandId로_필터링하면_해당_브랜드의_활성_상품만_반환한다() { + productService.register(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + productService.register(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = productService.findActiveProducts(1L, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getBrandId()).isEqualTo(1L); + } + + @Test + void 가격_오름차순으로_정렬할_수_있다() { + productService.register(1L, "비싼 운동화", new BigDecimal("90000"), 10, "설명"); + productService.register(1L, "싼 운동화", new BigDecimal("10000"), 10, "설명"); + productService.register(1L, "중간 운동화", new BigDecimal("50000"), 10, "설명"); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "price")); + Page result = productService.findActiveProducts(null, pageable); + + assertThat(result.getContent()).extracting(Product::getName) + .containsExactly("싼 운동화", "중간 운동화", "비싼 운동화"); + } + + @Test + void 좋아요_내림차순으로_정렬할_수_있다() { + Product p1 = productService.register(1L, "인기 상품", new BigDecimal("10000"), 10, "설명"); + productService.register(1L, "보통 상품", new BigDecimal("20000"), 20, "설명"); + Product p3 = productService.register(1L, "최고 인기", new BigDecimal("30000"), 30, "설명"); + p1.incrementLikeCount(); + p1.incrementLikeCount(); + productRepository.save(p1); + p3.incrementLikeCount(); + p3.incrementLikeCount(); + p3.incrementLikeCount(); + productRepository.save(p3); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "likeCount")); + Page result = productService.findActiveProducts(null, pageable); + + assertThat(result.getContent()).extracting(Product::getName) + .containsExactly("최고 인기", "인기 상품", "보통 상품"); + } + + @Test + void 결과가_없으면_빈_페이지를_반환한다() { + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = productService.findActiveProducts(null, pageable); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } + @Nested class 브랜드별_상품_일괄_삭제 { @@ -282,4 +392,67 @@ class 브랜드별_상품_일괄_삭제 { assertThat(found.isDeleted()).isFalse(); } } + + @Nested + class 재고_일괄_차감 { + + @Test + void 유효한_상품에_재고를_차감하면_차감된다() { + Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product2 = productService.register(1L, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠"); + + productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 5)); + + Product found1 = productRepository.findById(product1.getId()).orElseThrow(); + Product found2 = productRepository.findById(product2.getId()).orElseThrow(); + assertThat(found1.getStockQuantity()).isEqualTo(90); + assertThat(found2.getStockQuantity()).isEqualTo(45); + } + + @Test + void 미존재_상품이_포함되면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 10, 999L, 5))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품이 포함되어 있습니다"); + } + + @Test + void 삭제된_상품이_포함되면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + productRepository.save(product); + + assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 10))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품이 포함되어 있습니다"); + } + + @Test + void 재고가_부족한_상품이_있으면_예외() { + Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화"); + + assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 11))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("재고가 부족한 상품이 있습니다"); + } + + @Test + void 재고_부족_시_어떤_상품의_재고도_차감되지_않는다() { + Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product2 = productService.register(1L, "셔츠", new BigDecimal("30000"), 5, "멋진 셔츠"); + + assertThatThrownBy(() -> productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 10))) + .isInstanceOf(CoreException.class); + + Product found1 = productRepository.findById(product1.getId()).orElseThrow(); + Product found2 = productRepository.findById(product2.getId()).orElseThrow(); + assertThat(found1.getStockQuantity()).isEqualTo(100); + assertThat(found2.getStockQuantity()).isEqualTo(5); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 11c507207..20cb52246 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; @@ -49,7 +51,7 @@ class 상품_등록 { @Test void 유효한_정보로_등록하면_상품_정보가_반환된다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화" ); @@ -73,7 +75,7 @@ class 상품_등록 { @Test void 미존재_브랜드면_404_응답() { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( 999L, "운동화", new BigDecimal("50000"), 100, "설명" ); @@ -94,7 +96,7 @@ class 상품_등록 { Long brandId = registerBrand("나이키", "스포츠 브랜드"); deleteBrand(brandId); - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, "운동화", new BigDecimal("50000"), 100, "설명" ); @@ -110,7 +112,7 @@ class 상품_등록 { @Test void 요청_필드_규칙_위반_시_400_응답() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, "", new BigDecimal("50000"), 100, "설명" ); @@ -125,7 +127,7 @@ class 상품_등록 { @Test void 인증_헤더가_누락되면_401_응답() { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( 1L, "운동화", new BigDecimal("50000"), 100, "설명" ); @@ -143,7 +145,7 @@ class 상품_등록 { @Test void 인증에_실패하면_401_응답() { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( 1L, "운동화", new BigDecimal("50000"), 100, "설명" ); @@ -170,7 +172,7 @@ class 상품_수정 { void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화" ); @@ -192,7 +194,7 @@ class 상품_수정 { void 상품명만_보내면_상품명만_수정된다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -211,7 +213,7 @@ class 상품_수정 { void 소속_브랜드는_변경되지_않는다() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -226,7 +228,7 @@ class 상품_수정 { @Test void 미존재_상품이면_404_응답() { - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -248,7 +250,7 @@ class 상품_수정 { Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); deleteProduct(productId); - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -265,7 +267,7 @@ class 상품_수정 { void 요청_필드_규칙_위반_시_400_응답() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "", null, null, null ); @@ -280,7 +282,7 @@ class 상품_수정 { @Test void 인증_헤더가_누락되면_401_응답() { - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -298,7 +300,7 @@ class 상품_수정 { @Test void 인증에_실패하면_401_응답() { - ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -689,7 +691,7 @@ class 상품_목록_조회 { // --- 헬퍼 메서드 --- private Long registerBrand(String name, String description) { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + BrandRequest.Register request = new BrandRequest.Register(name, description); ResponseEntity> response = testRestTemplate.exchange( BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), @@ -707,7 +709,7 @@ private void deleteBrand(Long brandId) { } private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, name, price, stockQuantity, description ); ResponseEntity> response = postRegister(request); @@ -731,7 +733,7 @@ private ResponseEntity> deleteRequest(Long productId) { } private ResponseEntity> postRegister( - ProductAdminV1Dto.RegisterRequest request) { + ProductRequest.Register request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), @@ -740,7 +742,7 @@ private ResponseEntity> postRegis } private ResponseEntity> patchUpdate( - Long productId, ProductAdminV1Dto.UpdateRequest request) { + Long productId, ProductRequest.Update request) { return testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.PATCH, new HttpEntity<>(request, adminHeaders()), diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java new file mode 100644 index 000000000..522762bc2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java @@ -0,0 +1,362 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.product.ProductRequest; +import com.loopers.application.user.UserRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductUserApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String VALID_LDAP = "admin-ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 상품_목록_조회 { + + @Test + void 조건_없이_조회하면_활성_상품만_최신순으로_페이징하여_200_응답한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화C"), + () -> assertThat(response.getBody().data().content().get(1).name()).isEqualTo("운동화B"), + () -> assertThat(response.getBody().data().content().get(2).name()).isEqualTo("운동화A"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().page()).isEqualTo(0), + () -> assertThat(response.getBody().data().size()).isEqualTo(20) + ); + } + + @Test + void 삭제된_상품은_반환하지_않는다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + deleteProduct(deletedProductId); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("운동화A") + ); + } + + @Test + void brandId로_필터링하면_해당_브랜드에_속한_활성_상품만_반환한다() { + Long nikeId = registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = registerBrand("아디다스", "독일 브랜드"); + registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + + ResponseEntity>> response = + getList("?brandId=" + nikeId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductUserV1Dto.ProductResponse::brandId) + .containsOnly(nikeId) + ); + } + + @Test + void sort_RECENT이면_최신_등록순으로_정렬한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + + ResponseEntity>> response = + getList("?sort=RECENT"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductUserV1Dto.ProductResponse::name) + .containsExactly("운동화C", "운동화B", "운동화A") + ); + } + + @Test + void sort_PRICE_ASC이면_가격_오름차순으로_정렬한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "비싼 운동화", new BigDecimal("90000"), 10, "설명"); + registerProduct(brandId, "싼 운동화", new BigDecimal("10000"), 10, "설명"); + registerProduct(brandId, "중간 운동화", new BigDecimal("50000"), 10, "설명"); + + ResponseEntity>> response = + getList("?sort=PRICE_ASC"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductUserV1Dto.ProductResponse::name) + .containsExactly("싼 운동화", "중간 운동화", "비싼 운동화") + ); + } + + @Test + void sort_LIKES_DESC이면_좋아요_내림차순으로_정렬한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long p1 = registerProduct(brandId, "인기 상품", new BigDecimal("10000"), 10, "설명"); + registerProduct(brandId, "보통 상품", new BigDecimal("20000"), 20, "설명"); + Long p3 = registerProduct(brandId, "최고 인기", new BigDecimal("30000"), 30, "설명"); + + signUpUser("user1", "Pass1234!"); + signUpUser("user2", "Pass1234!"); + signUpUser("user3", "Pass1234!"); + + likeProduct(p1, "user1", "Pass1234!"); + likeProduct(p1, "user2", "Pass1234!"); + likeProduct(p3, "user1", "Pass1234!"); + likeProduct(p3, "user2", "Pass1234!"); + likeProduct(p3, "user3", "Pass1234!"); + + ResponseEntity>> response = + getList("?sort=LIKES_DESC"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductUserV1Dto.ProductResponse::name) + .containsExactly("최고 인기", "인기 상품", "보통 상품") + ); + } + + @Test + void sort를_지정하지_않으면_기본값_RECENT로_정렬한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + + ResponseEntity>> response = getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()) + .extracting(ProductUserV1Dto.ProductResponse::name) + .containsExactly("운동화B", "운동화A") + ); + } + + @Test + void sort에_잘못된_값을_보내면_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?sort=INVALID", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + ResponseEntity>> response = + getList("?brandId=999"); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + class 상품_상세_조회 { + + @Test + void 활성_상품을_조회하면_200_응답과_상품_상세_정보를_반환한다() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> response = getDetail(productId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("운동화"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().description()).isEqualTo("편한 운동화"), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull() + ); + } + + @Test + void 삭제된_상품을_조회하면_404_응답() { + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + deleteProduct(productId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + + @Test + void 해당_ID의_상품_데이터가_존재하지_않으면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + ); + } + } + + // --- 헬퍼 메서드 --- + + private Long registerBrand(String name, String description) { + BrandRequest.Register request = new BrandRequest.Register(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductRequest.Register request = new ProductRequest.Register( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private void signUpUser(String loginId, String password) { + UserRequest.SignUp request = new UserRequest.SignUp( + loginId, password, "홍길동", + LocalDate.of(2000, 1, 15), loginId + "@example.com" + ); + testRestTemplate.exchange( + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private void likeProduct(Long productId, String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity>> getList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getDetail(Long productId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } + +} From c891fb18c2370784d08b15c726458c6b9365dd14 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:24:02 +0900 Subject: [PATCH 40/68] =?UTF-8?q?refactor:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20DTO=EB=A5=BC=20Request/Command=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 26 ++++++----- .../loopers/application/like/LikeRequest.java | 26 +++++++++++ .../loopers/application/like/LikeService.java | 2 +- .../java/com/loopers/domain/like/Like.java | 16 +++++++ .../interfaces/api/like/LikeApiV1Spec.java | 3 +- .../interfaces/api/like/LikeV1Controller.java | 6 +-- .../interfaces/api/like/LikeV1Dto.java | 21 --------- .../like/LikeServiceIntegrationTest.java | 6 +-- .../com/loopers/domain/like/LikeTest.java | 43 +++++++++++++++++++ .../interfaces/api/like/LikeApiE2ETest.java | 25 ++++++----- 10 files changed, 118 insertions(+), 56 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 39e7611c6..b7a9de75a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -5,18 +5,19 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.like.Like; import com.loopers.domain.product.Product; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; -import java.util.List; import java.util.Map; -import java.util.function.Function; +import java.util.Set; import java.util.stream.Collectors; @Component +@Validated @RequiredArgsConstructor @Transactional(readOnly = true) public class LikeFacade { @@ -49,23 +50,20 @@ public void unlike(Long userId, Long productId) { // Query - public Page getLikedProducts(Long userId, Pageable pageable) { - Page likes = likeService.findLikedProducts(userId, pageable); + public Page getLikedProducts(Long userId, @Valid LikeRequest.ListLiked request) { + Page likes = likeService.findLikedActiveProducts(userId, request.toPageable()); - List productIds = likes.getContent().stream() + Set productIds = likes.getContent().stream() .map(Like::getProductId) - .toList(); + .collect(Collectors.toSet()); - Map productMap = productService.getProducts(productIds).stream() - .collect(Collectors.toMap(Product::getId, Function.identity())); + Map productMap = productService.getProductsMapByIds(productIds); - List brandIds = productMap.values().stream() + Set brandIds = productMap.values().stream() .map(Product::getBrandId) - .distinct() - .toList(); + .collect(Collectors.toSet()); - Map brandMap = brandService.getBrands(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, Function.identity())); + Map brandMap = brandService.getBrandsMapByIds(brandIds); return likes.map(like -> { Product product = productMap.get(like.getProductId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java new file mode 100644 index 000000000..de881e1e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java @@ -0,0 +1,26 @@ +package com.loopers.application.like; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +public record LikeRequest() { + + // Query + + public record ListLiked( + @PositiveOrZero Integer page, + @Min(1) @Max(100) Integer size + ) { + public ListLiked { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 6281401d3..5270bfc12 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -44,7 +44,7 @@ public boolean unlike(Long userId, Long productId) { // Query - public Page findLikedProducts(Long userId, Pageable pageable) { + public Page findLikedActiveProducts(Long userId, Pageable pageable) { return likeRepository.findAllByUserIdWithActiveProduct(userId, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index ea4271c86..34ec6cb53 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -1,5 +1,7 @@ package com.loopers.domain.like; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -41,9 +43,23 @@ private Like(Long userId, Long productId) { } public static Like create(Long userId, Long productId) { + validateUserId(userId); + validateProductId(productId); return new Like(userId, productId); } + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다"); + } + } + + private static void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다"); + } + } + @PrePersist private void prePersist() { this.createdAt = ZonedDateTime.now(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java index 19526bde2..74aaed3fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.like; +import com.loopers.application.like.LikeRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; @@ -30,5 +31,5 @@ public interface LikeApiV1Spec { description = "사용자가 좋아요한 상품 목록을 좋아요 등록순(최신순)으로 페이징하여 조회합니다." ) ApiResponse> list( - LikeV1Dto.ListRequest request, AuthenticatedUser authUser); + LikeRequest.ListLiked request, AuthenticatedUser authUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 0faeec09c..e3a5dc685 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -2,11 +2,11 @@ import com.loopers.application.like.LikeFacade; import com.loopers.application.like.LikeProductInfo; +import com.loopers.application.like.LikeRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -46,9 +46,9 @@ public ApiResponse unlike( @GetMapping("/api/v1/likes") @Override public ApiResponse> list( - @Valid LikeV1Dto.ListRequest request, + LikeRequest.ListLiked request, @AuthUser AuthenticatedUser authUser) { - Page likedProducts = likeFacade.getLikedProducts(authUser.id(), request.toPageable()); + Page likedProducts = likeFacade.getLikedProducts(authUser.id(), request); return ApiResponse.success(PageResponse.from(likedProducts, LikeV1Dto.LikeProductResponse::from)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 98d33f48a..1a2145257 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,33 +1,12 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeProductInfo; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.PositiveOrZero; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import java.math.BigDecimal; import java.time.LocalDateTime; public class LikeV1Dto { - // Query - - public record ListRequest( - @PositiveOrZero Integer page, - @Min(1) @Max(100) Integer size - ) { - public ListRequest { - if (page == null) page = 0; - if (size == null) size = 20; - } - - public Pageable toPageable() { - return PageRequest.of(page, size); - } - } - // Response public record LikeProductResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java index 7dba3ddef..9a393e79b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceIntegrationTest.java @@ -97,7 +97,7 @@ class 좋아요_목록_조회 { likeService.like(1L, product1.getId()); likeService.like(1L, product2.getId()); - Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + Page result = likeService.findLikedActiveProducts(1L, PageRequest.of(0, 10)); assertThat(result.getContent()).hasSize(2); assertThat(result.getContent().get(0).getProductId()).isEqualTo(product2.getId()); @@ -113,7 +113,7 @@ class 좋아요_목록_조회 { product2.delete(); productRepository.save(product2); - Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + Page result = likeService.findLikedActiveProducts(1L, PageRequest.of(0, 10)); assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getProductId()).isEqualTo(product1.getId()); @@ -122,7 +122,7 @@ class 좋아요_목록_조회 { @Test void 좋아요가_없으면_빈_목록을_반환한다() { - Page result = likeService.findLikedProducts(1L, PageRequest.of(0, 10)); + Page result = likeService.findLikedActiveProducts(1L, PageRequest.of(0, 10)); assertThat(result.getContent()).isEmpty(); assertThat(result.getTotalElements()).isZero(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..ab9f1cf95 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,43 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class LikeTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_좋아요가_생성된다() { + Like like = Like.create(1L, 1L); + + assertThat(like.getUserId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(1L); + } + + @Test + void 사용자ID가_null이면_예외() { + assertThatThrownBy(() -> Like.create(null, 1L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("사용자 ID는 필수입니다"); + } + + @Test + void 상품ID가_null이면_예외() { + assertThatThrownBy(() -> Like.create(1L, null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("상품 ID는 필수입니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 23ce14f67..57c59c10e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -1,5 +1,8 @@ package com.loopers.interfaces.api.like; +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.product.ProductRequest; +import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; @@ -89,13 +92,11 @@ class 좋아요_등록 { postLike(productId); ResponseEntity> response = postLike(productId); + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> { - ResponseEntity>> productResponse = - getProductList("?status=ACTIVE"); - assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); - } + () -> assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1) ); } @@ -209,13 +210,11 @@ class 좋아요_취소 { ResponseEntity> response = deleteLike(productId); + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> { - ResponseEntity>> productResponse = - getProductList("?status=ACTIVE"); - assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); - } + () -> assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0) ); } @@ -397,7 +396,7 @@ class 좋아요_목록_조회 { // --- 헬퍼 메서드 --- private void signUpUser() { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( + UserRequest.SignUp request = new UserRequest.SignUp( LOGIN_ID, LOGIN_PW, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com" ); @@ -408,7 +407,7 @@ private void signUpUser() { } private Long registerBrand(String name, String description) { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + BrandRequest.Register request = new BrandRequest.Register(name, description); ResponseEntity> response = testRestTemplate.exchange( BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), @@ -418,7 +417,7 @@ private Long registerBrand(String name, String description) { } private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, name, price, stockQuantity, description ); ResponseEntity> response = testRestTemplate.exchange( From 93284f9c7f69b43de776c52cef10b134d3d986e7 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 26 Feb 2026 20:42:22 +0900 Subject: [PATCH 41/68] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=A5=BC=20=EB=A9=B1=EB=93=B1=20=EC=97=B0?= =?UTF-8?q?=EC=82=B0=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/product/ProductService.java | 1 - .../product/ProductServiceIntegrationTest.java | 11 ++++++----- .../api/product/ProductAdminApiE2ETest.java | 13 +++---------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 3ad96b77b..02490635d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -46,7 +46,6 @@ public Product update(Long productId, String name, BigDecimal price, Integer sto public void delete(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); - product.validateNotDeleted(); product.delete(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 9ef6a0ffc..a5fc4b5b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -118,14 +118,15 @@ class 상품_삭제 { } @Test - void 삭제된_상품이면_예외() { + void 이미_삭제된_상품을_다시_삭제해도_정상_처리된다() { Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); productService.delete(product.getId()); - assertThatThrownBy(() -> productService.delete(product.getId())) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) - .hasMessageContaining("존재하지 않는 상품입니다"); + assertThatCode(() -> productService.delete(product.getId())) + .doesNotThrowAnyException(); + + Product found = productRepository.findById(product.getId()).orElseThrow(); + assertThat(found.isDeleted()).isTrue(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 20cb52246..219c1f942 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -348,21 +348,14 @@ class 상품_삭제 { } @Test - void 삭제된_상품이면_404_응답() { + void 이미_삭제된_상품을_다시_삭제해도_200_응답() { Long brandId = registerBrand("나이키", "스포츠 브랜드"); Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); deleteProduct(productId); - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/" + productId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference<>() {} - ); + ResponseEntity> response = deleteRequest(productId); - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), - () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") - ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test From 1a0b0f3f3fb765c18d4906330b690e9ca6423d51 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 01:12:11 +0900 Subject: [PATCH 42/68] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 16 + .../loopers/application/order/OrderInfo.java | 21 +- .../application/order/OrderRequest.java | 36 ++ .../application/order/OrderService.java | 23 ++ .../loopers/domain/order/OrderRepository.java | 11 + .../order/OrderJpaRepository.java | 23 ++ .../order/OrderRepositoryImpl.java | 16 + .../interfaces/api/order/OrderApiV1Spec.java | 21 + .../api/order/OrderV1Controller.java | 25 ++ .../interfaces/api/order/OrderV1Dto.java | 19 +- .../order/OrderServiceIntegrationTest.java | 80 ++++ .../interfaces/api/order/OrderApiE2ETest.java | 367 +++++++++++++++++- 12 files changed, 648 insertions(+), 10 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 7f48cd2f9..4b19763e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -7,6 +7,7 @@ import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -65,4 +66,19 @@ public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { Order order = orderService.createOrder(OrderCommand.Create.of(userId, orderItems)); return OrderInfo.from(order); } + + // Query + + public OrderInfo getOrderDetail(Long userId, Long orderId) { + Order order = orderService.findOrderById(orderId, userId); + return OrderInfo.from(order); + } + + public Page getOrderList(Long userId, @Valid OrderRequest.ListByUser request) { + if (request.startDate() != null && request.endDate() != null && request.startDate().isAfter(request.endDate())) { + throw new CoreException(ErrorType.BAD_REQUEST, "시작일은 종료일 이전이어야 합니다"); + } + Page orders = orderService.findOrdersByUserIdAndDateRange(userId, request.startDateTime(), request.endDateTime(), request.toPageable()); + return orders.map(OrderInfo.OrderSummary::from); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 5ecc25e53..c2e8be306 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -4,14 +4,14 @@ import com.loopers.domain.order.OrderItem; import java.math.BigDecimal; -import java.time.ZonedDateTime; +import java.time.LocalDateTime; import java.util.List; public record OrderInfo( Long id, BigDecimal totalAmount, List orderItems, - ZonedDateTime createdAt + LocalDateTime createdAt ) { public record OrderItemInfo( @@ -41,7 +41,22 @@ public static OrderInfo from(Order order) { order.getId(), order.getTotalAmount(), items, - order.getCreatedAt() + order.getCreatedAt().toLocalDateTime() ); } + + public record OrderSummary( + Long id, + BigDecimal totalAmount, + LocalDateTime createdAt + ) { + + public static OrderSummary from(Order order) { + return new OrderSummary( + order.getId(), + order.getTotalAmount(), + order.getCreatedAt().toLocalDateTime() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java index 0a702314d..6feec1cbe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -4,9 +4,16 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.List; +import java.util.Objects; public record OrderRequest() { @@ -29,4 +36,33 @@ public record PlaceItem( Integer quantity ) { } + + // Query + + public record ListByUser( + LocalDate startDate, + LocalDate endDate, + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") Integer page, + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") Integer size + ) { + public ListByUser { + page = Objects.requireNonNullElse(page, 0); + size = Objects.requireNonNullElse(size, 20); + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + + public ZonedDateTime startDateTime() { + return startDate != null + ? startDate.atStartOfDay(ZoneId.systemDefault()) : null; + } + + public ZonedDateTime endDateTime() { + return endDate != null + ? endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()) : null; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 527790a8e..7cded81ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -2,10 +2,16 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.ZonedDateTime; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -30,4 +36,21 @@ public Order createOrder(OrderCommand.Create command) { return orderRepository.save(order); } + + // Query + + public Order findOrderById(Long orderId, Long userId) { + Order order = orderRepository.findByIdWithItems(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다")); + + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다"); + } + + return order; + } + + public Page findOrdersByUserIdAndDateRange(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 2dfaa1866..36547bebb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -1,7 +1,18 @@ package com.loopers.domain.order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.Optional; + public interface OrderRepository { // Command Order save(Order order); + + // Query + Optional findByIdWithItems(Long id); + + Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index f2ee62050..19ce79a30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,7 +1,30 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.Optional; public interface OrderJpaRepository extends JpaRepository { + + // Query + @Query("SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :id") + Optional findByIdWithItems(@Param("id") Long id); + + @Query(value = "SELECT o FROM Order o WHERE o.userId = :userId " + + "AND (:startDate IS NULL OR o.createdAt >= :startDate) " + + "AND (:endDate IS NULL OR o.createdAt < :endDate) " + + "ORDER BY o.createdAt DESC", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.userId = :userId " + + "AND (:startDate IS NULL OR o.createdAt >= :startDate) " + + "AND (:endDate IS NULL OR o.createdAt < :endDate)") + Page findAllByUserIdAndCreatedAtBetween(@Param("userId") Long userId, + @Param("startDate") ZonedDateTime startDate, + @Param("endDate") ZonedDateTime endDate, + Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 5843c14e0..417e9f1e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -3,8 +3,13 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.time.ZonedDateTime; +import java.util.Optional; + @Repository @RequiredArgsConstructor public class OrderRepositoryImpl implements OrderRepository { @@ -16,4 +21,15 @@ public class OrderRepositoryImpl implements OrderRepository { public Order save(Order order) { return orderJpaRepository.save(order); } + + // Query + @Override + public Optional findByIdWithItems(Long id) { + return orderJpaRepository.findByIdWithItems(id); + } + + @Override + public Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java index 089b5a10c..f99f79c87 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java @@ -2,6 +2,7 @@ import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,4 +20,24 @@ ApiResponse createOrder( AuthenticatedUser user, OrderRequest.Place request ); + + // Query + + @Operation( + summary = "주문 상세 조회", + description = "본인의 주문 상세 정보를 조회합니다." + ) + ApiResponse getOrderDetail( + AuthenticatedUser user, + Long orderId + ); + + @Operation( + summary = "주문 목록 조회", + description = "본인의 주문 내역을 기간별로 조회합니다." + ) + ApiResponse> listOrders( + AuthenticatedUser user, + OrderRequest.ListByUser request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index b4d790ddc..c889e17f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -4,9 +4,13 @@ import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -29,4 +33,25 @@ public ApiResponse createOrder( OrderInfo info = orderFacade.createOrder(user.id(), request); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } + + // Query + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail( + @AuthUser AuthenticatedUser user, + @PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderDetail(user.id(), orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> listOrders( + @AuthUser AuthenticatedUser user, + OrderRequest.ListByUser request) { + Page orders = orderFacade.getOrderList(user.id(), request); + PageResponse pageResponse = PageResponse.from(orders, OrderV1Dto.OrderListResponse::from); + return ApiResponse.success(pageResponse); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 396278862..131f7c7d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -3,7 +3,7 @@ import com.loopers.application.order.OrderInfo; import java.math.BigDecimal; -import java.time.ZonedDateTime; +import java.time.LocalDateTime; import java.util.List; public class OrderV1Dto { @@ -14,7 +14,7 @@ public record OrderResponse( Long id, BigDecimal totalAmount, List orderItems, - ZonedDateTime createdAt + LocalDateTime createdAt ) { public static OrderResponse from(OrderInfo info) { @@ -48,4 +48,19 @@ public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { ); } } + + public record OrderListResponse( + Long id, + BigDecimal totalAmount, + LocalDateTime createdAt + ) { + + public static OrderListResponse from(OrderInfo.OrderSummary summary) { + return new OrderListResponse( + summary.id(), + summary.totalAmount(), + summary.createdAt() + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index f9d6ffbb8..a8f499f63 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -3,6 +3,8 @@ import com.loopers.domain.order.Order; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -11,11 +13,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @@ -73,4 +78,79 @@ class 주문_생성 { assertThat(order.getOrderItems().get(0).getPrice()).isEqualByComparingTo(new BigDecimal("50000")); } } + + @Nested + class 주문_상세_조회 { + + @Test + void 본인의_주문을_조회하면_주문_정보를_반환한다() { + Order created = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 2) + ))); + + Order order = orderService.findOrderById(created.getId(), 1L); + + assertThat(order.getId()).isEqualTo(created.getId()); + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("100000")); + } + + @Test + void 존재하지_않는_주문이면_예외() { + assertThatThrownBy(() -> orderService.findOrderById(999L, 1L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + void 본인의_주문이_아니면_예외() { + Order created = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + + assertThatThrownBy(() -> orderService.findOrderById(created.getId(), 2L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + class 주문_목록_조회 { + + @Test + void 사용자_ID로_조회하면_해당_사용자의_주문만_반환한다() { + orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(2L, "셔츠", new BigDecimal("30000"), 1) + ))); + orderService.createOrder(OrderCommand.Create.of(2L, List.of( + OrderCommand.CreateItem.of(3L, "바지", new BigDecimal("40000"), 1) + ))); + + Page result = orderService.findOrdersByUserIdAndDateRange(1L, null, null, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).allMatch(order -> order.getUserId().equals(1L)); + } + + @Test + void 페이징이_올바르게_동작한다() { + for (int i = 0; i < 5; i++) { + orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of((long) (i + 1), "상품" + i, new BigDecimal("10000"), 1) + ))); + } + + Page result = orderService.findOrdersByUserIdAndDateRange(1L, null, null, PageRequest.of(0, 2)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.getTotalPages()).isEqualTo(3); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index 5f64e1068..5cf7de996 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -1,6 +1,9 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.brand.BrandRequest; import com.loopers.application.order.OrderRequest; +import com.loopers.application.product.ProductRequest; +import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; @@ -280,12 +283,336 @@ class 주문_요청 { } } + @Nested + class 주문_목록_조회 { + + @Test + void 조건_없이_조회하면_본인의_주문만_최신순으로_페이징하여_200_응답한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId1, 1)))); + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId2, 2)))); + + ResponseEntity>> response = + getOrderList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2), + () -> assertThat(response.getBody().data().content().get(0).createdAt()) + .isAfterOrEqualTo(response.getBody().data().content().get(1).createdAt()) + ); + } + + @Test + void 타인의_주문은_반환하지_않는다() { + signUp(); + signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + postOrderAs("otheruser", "Other1234!", + new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + + ResponseEntity>> response = + getOrderList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1) + ); + } + + @Test + void 시작일만_지정하면_해당일_이후_주문만_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + + ResponseEntity>> responseToday = + getOrderList("?startDate=" + today); + ResponseEntity>> responseTomorrow = + getOrderList("?startDate=" + tomorrow); + + assertAll( + () -> assertThat(responseToday.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseToday.getBody().data().content()).hasSize(1), + () -> assertThat(responseTomorrow.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseTomorrow.getBody().data().content()).isEmpty() + ); + } + + @Test + void 종료일만_지정하면_해당일_이전_주문만_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + + ResponseEntity>> responseToday = + getOrderList("?endDate=" + today); + ResponseEntity>> responseYesterday = + getOrderList("?endDate=" + yesterday); + + assertAll( + () -> assertThat(responseToday.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseToday.getBody().data().content()).hasSize(1), + () -> assertThat(responseYesterday.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseYesterday.getBody().data().content()).isEmpty() + ); + } + + @Test + void 시작일과_종료일을_모두_지정하면_해당_기간_내_주문만_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + + LocalDate today = LocalDate.now(); + + ResponseEntity>> responseInRange = + getOrderList("?startDate=" + today + "&endDate=" + today); + ResponseEntity>> responseOutOfRange = + getOrderList("?startDate=" + today.plusDays(1) + "&endDate=" + today.plusDays(2)); + + assertAll( + () -> assertThat(responseInRange.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseInRange.getBody().data().content()).hasSize(1), + () -> assertThat(responseOutOfRange.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(responseOutOfRange.getBody().data().content()).isEmpty() + ); + } + + @Test + void 시작일이_종료일보다_미래이면_400_응답() { + signUp(); + + LocalDate today = LocalDate.now(); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?startDate=" + today.plusDays(1) + "&endDate=" + today, + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().message()).contains("시작일은 종료일 이전이어야 합니다") + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + signUp(); + + ResponseEntity>> response = + getOrderList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isZero() + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + signUp(); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + class 주문_상세_조회 { + + @Test + void 본인의_주문을_조회하면_200_응답과_주문_상세_정보를_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + + ResponseEntity> createResponse = postOrder( + new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId1, 2), + new OrderRequest.PlaceItem(productId2, 1) + )) + ); + Long orderId = createResponse.getBody().data().id(); + + ResponseEntity> response = getOrderDetail(orderId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().totalAmount()).isEqualByComparingTo(new BigDecimal("130000")), + () -> assertThat(response.getBody().data().orderItems()).hasSize(2), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @Test + void 주문_상품은_스냅샷_정보로_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> createResponse = postOrder( + new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 3))) + ); + Long orderId = createResponse.getBody().data().id(); + + ResponseEntity> response = getOrderDetail(orderId); + + OrderV1Dto.OrderItemResponse item = response.getBody().data().orderItems().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo("운동화"), + () -> assertThat(item.price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(item.quantity()).isEqualTo(3), + () -> assertThat(item.orderPrice()).isEqualByComparingTo(new BigDecimal("150000")) + ); + } + + @Test + void 존재하지_않는_주문이면_404_응답() { + signUp(); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 주문입니다") + ); + } + + @Test + void 본인의_주문이_아니면_404_응답() { + signUp(); + signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + ResponseEntity> createResponse = postOrderAs( + "otheruser", "Other1234!", + new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1))) + ); + Long otherOrderId = createResponse.getBody().data().id(); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + otherOrderId, + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 주문입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", + HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "notexist"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + // --- 헬퍼 메서드 --- private void signUp() { - UserV1Dto.SignUpRequest request = new UserV1Dto.SignUpRequest( - USER_LOGIN_ID, USER_PASSWORD, "홍길동", - LocalDate.of(2000, 1, 15), "test@example.com" + signUp(USER_LOGIN_ID, USER_PASSWORD, "홍길동", "test@example.com"); + } + + private void signUp(String loginId, String password, String name, String email) { + UserRequest.SignUp request = new UserRequest.SignUp( + loginId, password, name, + LocalDate.of(2000, 1, 15), email ); testRestTemplate.exchange( USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), @@ -294,7 +621,7 @@ private void signUp() { } private Long registerBrand(String name, String description) { - BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(name, description); + BrandRequest.Register request = new BrandRequest.Register(name, description); ResponseEntity> response = testRestTemplate.exchange( BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), @@ -304,7 +631,7 @@ private Long registerBrand(String name, String description) { } private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductAdminV1Dto.RegisterRequest request = new ProductAdminV1Dto.RegisterRequest( + ProductRequest.Register request = new ProductRequest.Register( brandId, name, price, stockQuantity, description ); ResponseEntity> response = testRestTemplate.exchange( @@ -332,6 +659,36 @@ private ResponseEntity> postOrder(OrderReq ); } + private ResponseEntity> postOrderAs( + String loginId, String password, OrderRequest.Place request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getOrderDetail(Long orderId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + orderId, + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity>> getOrderList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, + HttpMethod.GET, + new HttpEntity<>(userHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + private HttpHeaders adminHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", VALID_LDAP); From 85d87324a9aa8d4edc8fd59d35376dcb4f004310 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 01:37:58 +0900 Subject: [PATCH 43/68] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Command=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserCommand.java | 18 +++++++ .../loopers/application/user/UserFacade.java | 6 ++- .../loopers/application/user/UserService.java | 15 +++--- .../user/UserServiceIntegrationTest.java | 49 ++++++++++-------- .../com/loopers/domain/user/UserTest.java | 50 +++++++++++++++++++ .../interfaces/api/user/UserApiE2ETest.java | 35 ++++++++++--- 6 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserCommand.java new file mode 100644 index 000000000..03cdf4ddd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserCommand.java @@ -0,0 +1,18 @@ +package com.loopers.application.user; + +import java.time.LocalDate; + +public record UserCommand() { + + public record SignUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + public static SignUp of(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return new SignUp(loginId, rawPassword, name, birthDate, email); + } + } + + public record ChangePassword(Long id, String newRawPassword) { + public static ChangePassword of(Long id, String newRawPassword) { + return new ChangePassword(id, newRawPassword); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 9d1af7d15..c500a09fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -19,15 +19,17 @@ public class UserFacade { @Transactional public UserInfo signUp(@Valid UserRequest.SignUp request) { - User user = userService.signUp( + UserCommand.SignUp command = UserCommand.SignUp.of( request.loginId(), request.password(), request.name(), request.birthDate(), request.email()); + User user = userService.signUp(command); return UserInfo.from(user); } @Transactional public void changePassword(Long id, @Valid UserRequest.ChangePassword request) { - userService.changePassword(id, request.newPassword()); + UserCommand.ChangePassword command = UserCommand.ChangePassword.of(id, request.newPassword()); + userService.changePassword(command); } // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index e761ecef4..a195c9581 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -9,8 +9,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,20 +20,21 @@ public class UserService { // Command @Transactional - public User signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { - if (userRepository.existsByLoginId(loginId)) { + public User signUp(UserCommand.SignUp command) { + if (userRepository.existsByLoginId(command.loginId())) { throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다"); } - User user = User.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); + User user = User.create(command.loginId(), command.rawPassword(), command.name(), + command.birthDate(), command.email(), passwordEncoder); return userRepository.save(user); } @Transactional - public void changePassword(Long id, String newRawPassword) { - User user = userRepository.findById(id) + public void changePassword(UserCommand.ChangePassword command) { + User user = userRepository.findById(command.id()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); - user.changePassword(newRawPassword, passwordEncoder); + user.changePassword(command.newRawPassword(), passwordEncoder); } // Query diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index 054bdaceb..18aab1755 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -36,26 +36,22 @@ class 회원가입 { @Test void 유효한_정보로_회원가입하면_회원이_생성된다() { - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(2000, 1, 15); - String email = "test@example.com"; + var command = UserCommand.SignUp.of("testuser", "Test1234!", "홍길동", + LocalDate.of(2000, 1, 15), "test@example.com"); - User result = userService.signUp(loginId, rawPassword, name, birthDate, email); + User result = userService.signUp(command); - assertThat(result.getLoginId()).isEqualTo(loginId); - assertThat(result.getName()).isEqualTo(name); - assertThat(result.getBirthDate()).isEqualTo(birthDate); - assertThat(result.getEmail()).isEqualTo(email); + assertThat(result.getLoginId()).isEqualTo("testuser"); + assertThat(result.getName()).isEqualTo("홍길동"); + assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(2000, 1, 15)); + assertThat(result.getEmail()).isEqualTo("test@example.com"); } @Test void 이미_존재하는_로그인ID로_가입하면_예외() { - String loginId = "testuser"; - userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - assertThatThrownBy(() -> userService.signUp(loginId, "Test5678!", "김철수", LocalDate.of(1995, 5, 20), "other@example.com")) + assertThatThrownBy(() -> signUp("testuser", "Test5678!", "김철수", LocalDate.of(1995, 5, 20), "other@example.com")) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 사용 중인 로그인 ID입니다"); @@ -69,7 +65,7 @@ class 인증 { void 유효한_인증정보로_인증하면_회원을_반환한다() { String loginId = "testuser"; String rawPassword = "Test1234!"; - userService.signUp(loginId, rawPassword, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + signUp(loginId, rawPassword, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); User user = userService.authenticate(loginId, rawPassword); @@ -87,7 +83,7 @@ class 인증 { @Test void 비밀번호가_일치하지_않으면_예외() { String loginId = "testuser"; - userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); assertThatThrownBy(() -> userService.authenticate(loginId, "WrongPass1!")) .isInstanceOf(CoreException.class) @@ -102,11 +98,10 @@ class 비밀번호_변경 { @Test void 유효한_새_비밀번호로_변경하면_성공한다() { String loginId = "testuser"; - String rawPassword = "Test1234!"; String newPassword = "NewPass123!"; - User user = userService.signUp(loginId, rawPassword, "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + User user = signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - userService.changePassword(user.getId(), newPassword); + userService.changePassword(UserCommand.ChangePassword.of(user.getId(), newPassword)); assertThatCode(() -> userService.authenticate(loginId, newPassword)) .doesNotThrowAnyException(); @@ -115,9 +110,9 @@ class 비밀번호_변경 { @Test void 변경_후_이전_비밀번호로_인증하면_예외() { String loginId = "testuser"; - User user = userService.signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + User user = signUp(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); - userService.changePassword(user.getId(), "NewPass123!"); + userService.changePassword(UserCommand.ChangePassword.of(user.getId(), "NewPass123!")); assertThatThrownBy(() -> userService.authenticate(loginId, "Test1234!")) .isInstanceOf(CoreException.class); @@ -127,6 +122,16 @@ class 비밀번호_변경 { @Nested class 회원_조회 { + @Test + void 존재하는_ID로_조회하면_회원을_반환한다() { + User saved = signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + User result = userService.getById(saved.getId()); + + assertThat(result.getId()).isEqualTo(saved.getId()); + assertThat(result.getLoginId()).isEqualTo("testuser"); + } + @Test void 존재하지_않는_ID로_조회하면_예외() { assertThatThrownBy(() -> userService.getById(999L)) @@ -134,4 +139,8 @@ class 회원_조회 { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } } + + private User signUp(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return userService.signUp(UserCommand.SignUp.of(loginId, rawPassword, name, birthDate, email)); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 2158a5c0d..62933c7de 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -48,6 +48,31 @@ class 생성 { .hasMessageContaining("로그인 ID"); } + @Test + void 로그인ID가_최소길이_미만이면_예외() { + String loginId = "abc"; // 3자 + + assertThatThrownBy(() -> User.create(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("로그인 ID는 4~20자여야 합니다"); + } + + @Test + void 로그인ID가_최대길이_초과면_예외() { + String loginId = "a".repeat(21); // 21자 + + assertThatThrownBy(() -> User.create(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("로그인 ID는 4~20자여야 합니다"); + } + + @ParameterizedTest + @ValueSource(strings = {"abcd", "abcdefghijklmnopqrst"}) + void 로그인ID가_경계값이면_생성된다(String loginId) { + assertThatCode(() -> User.create(loginId, "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .doesNotThrowAnyException(); + } + @ParameterizedTest @ValueSource(strings = {"test user", "test@user", "test-user", "테스트유저", "test_user"}) void 로그인ID가_영문숫자가_아니면_예외(String loginId) { @@ -65,6 +90,31 @@ class 생성 { .hasMessageContaining("이름"); } + @Test + void 이름이_최소길이_미만이면_예외() { + String name = "홍"; // 1자 + + assertThatThrownBy(() -> User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이름은 2~20자여야 합니다"); + } + + @Test + void 이름이_최대길이_초과면_예외() { + String name = "가".repeat(21); // 21자 + + assertThatThrownBy(() -> User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이름은 2~20자여야 합니다"); + } + + @ParameterizedTest + @ValueSource(strings = {"홍길", "가나다라마바사아자차카타파하아아아아자차"}) + void 이름이_경계값이면_생성된다(String name) { + assertThatCode(() -> User.create("testuser", "Test1234!", name, LocalDate.of(2000, 1, 15), "test@example.com", PASSWORD_ENCODER)) + .doesNotThrowAnyException(); + } + @Test void 생년월일이_null이면_예외() { assertThatThrownBy(() -> User.create("testuser", "Test1234!", "홍길동", null, "test@example.com", PASSWORD_ENCODER)) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index a1eddd523..58ff981ca 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -77,7 +77,10 @@ class 회원가입 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().message()).contains("이미 사용 중인 로그인 ID입니다") + ); } @Test @@ -137,7 +140,10 @@ class 내_정보_조회 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); } @Test @@ -150,7 +156,10 @@ class 내_정보_조회 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); } @Test @@ -161,7 +170,10 @@ class 내_정보_조회 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); } } @@ -198,7 +210,10 @@ class 비밀번호_변경 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().message()).contains("현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다") + ); } @Test @@ -213,7 +228,10 @@ class 비밀번호_변경 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); } @Test @@ -226,7 +244,10 @@ class 비밀번호_변경 { new ParameterizedTypeReference<>() {} ); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); } @Test From 3024a55f6593ed147c7e5f7f98325c92af17494b Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 01:39:03 +0900 Subject: [PATCH 44/68] =?UTF-8?q?=20feat:=20=EC=A3=BC=EB=AC=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Admin=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 10 + .../loopers/application/order/OrderInfo.java | 19 + .../application/order/OrderRequest.java | 15 + .../application/order/OrderService.java | 9 + .../loopers/domain/order/OrderRepository.java | 2 + .../order/OrderJpaRepository.java | 4 + .../order/OrderRepositoryImpl.java | 5 + .../api/order/OrderAdminApiV1Spec.java | 27 ++ .../api/order/OrderAdminV1Controller.java | 40 ++ .../interfaces/api/order/OrderAdminV1Dto.java | 70 ++++ .../order/OrderServiceIntegrationTest.java | 61 +++ .../api/order/OrderAdminApiE2ETest.java | 347 ++++++++++++++++++ 12 files changed, 609 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4b19763e8..69f1f4c17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -81,4 +81,14 @@ public Page getOrderList(Long userId, @Valid OrderReques Page orders = orderService.findOrdersByUserIdAndDateRange(userId, request.startDateTime(), request.endDateTime(), request.toPageable()); return orders.map(OrderInfo.OrderSummary::from); } + + public OrderInfo getAdminOrderDetail(Long orderId) { + Order order = orderService.findOrderById(orderId); + return OrderInfo.from(order); + } + + public Page getAdminOrderList(@Valid OrderRequest.ListAll request) { + Page orders = orderService.findAllOrders(request.toPageable()); + return orders.map(OrderInfo.OrderAdminSummary::from); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index c2e8be306..dce539a56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -9,6 +9,7 @@ public record OrderInfo( Long id, + Long userId, BigDecimal totalAmount, List orderItems, LocalDateTime createdAt @@ -39,6 +40,7 @@ public static OrderInfo from(Order order) { .toList(); return new OrderInfo( order.getId(), + order.getUserId(), order.getTotalAmount(), items, order.getCreatedAt().toLocalDateTime() @@ -59,4 +61,21 @@ public static OrderSummary from(Order order) { ); } } + + public record OrderAdminSummary( + Long id, + Long userId, + BigDecimal totalAmount, + LocalDateTime createdAt + ) { + + public static OrderAdminSummary from(Order order) { + return new OrderAdminSummary( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + order.getCreatedAt().toLocalDateTime() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java index 6feec1cbe..b1c0538df 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -65,4 +65,19 @@ public ZonedDateTime endDateTime() { ? endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()) : null; } } + + public record ListAll( + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") Integer page, + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") Integer size + ) { + public ListAll { + page = Objects.requireNonNullElse(page, 0); + size = Objects.requireNonNullElse(size, 20); + } + + public Pageable toPageable() { + return PageRequest.of(page, size); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 7cded81ae..7b8df07fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -53,4 +53,13 @@ public Order findOrderById(Long orderId, Long userId) { public Page findOrdersByUserIdAndDateRange(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); } + + public Order findOrderById(Long orderId) { + return orderRepository.findByIdWithItems(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다")); + } + + public Page findAllOrders(Pageable pageable) { + return orderRepository.findAll(pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 36547bebb..c221a90da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -15,4 +15,6 @@ public interface OrderRepository { Optional findByIdWithItems(Long id); Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable); + + Page findAll(Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 19ce79a30..656b4b04d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -27,4 +27,8 @@ Page findAllByUserIdAndCreatedAtBetween(@Param("userId") Long userId, @Param("startDate") ZonedDateTime startDate, @Param("endDate") ZonedDateTime endDate, Pageable pageable); + + @Query(value = "SELECT o FROM Order o ORDER BY o.createdAt DESC", + countQuery = "SELECT COUNT(o) FROM Order o") + Page findAllByOrderByCreatedAtDesc(Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 417e9f1e0..0b17be85d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -32,4 +32,9 @@ public Optional findByIdWithItems(Long id) { public Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAllByOrderByCreatedAtDesc(pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java new file mode 100644 index 000000000..7bf62cbf6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order Admin API", description = "주문 관리 API") +public interface OrderAdminApiV1Spec { + + // Query + + @Operation( + summary = "주문 목록 조회 (Admin)", + description = "전체 주문을 최신순으로 페이징 조회합니다." + ) + ApiResponse> list( + OrderRequest.ListAll request + ); + + @Operation( + summary = "주문 상세 조회 (Admin)", + description = "특정 주문의 상세 정보를 조회합니다." + ) + ApiResponse detail(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..1a17421ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api-admin/v1/orders") +@RequiredArgsConstructor +public class OrderAdminV1Controller implements OrderAdminApiV1Spec { + + private final OrderFacade orderFacade; + + // Query + + @GetMapping + @Override + public ApiResponse> list( + OrderRequest.ListAll request) { + Page orders = orderFacade.getAdminOrderList(request); + PageResponse pageResponse = + PageResponse.from(orders, OrderAdminV1Dto.OrderListResponse::from); + return ApiResponse.success(pageResponse); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse detail(@PathVariable Long orderId) { + OrderInfo info = orderFacade.getAdminOrderDetail(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java new file mode 100644 index 000000000..cae9bdab8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,70 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + // Response + + public record OrderResponse( + Long id, + Long userId, + BigDecimal totalAmount, + List orderItems, + LocalDateTime createdAt + ) { + + public static OrderResponse from(OrderInfo info) { + List items = info.orderItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.id(), + info.userId(), + info.totalAmount(), + items, + info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + BigDecimal price, + Integer quantity, + BigDecimal orderPrice + ) { + + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.price(), + item.quantity(), + item.orderPrice() + ); + } + } + + public record OrderListResponse( + Long id, + Long userId, + BigDecimal totalAmount, + LocalDateTime createdAt + ) { + + public static OrderListResponse from(OrderInfo.OrderAdminSummary summary) { + return new OrderListResponse( + summary.id(), + summary.userId(), + summary.totalAmount(), + summary.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index a8f499f63..571f31614 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -153,4 +153,65 @@ class 주문_목록_조회 { assertThat(result.getTotalPages()).isEqualTo(3); } } + + @Nested + class 주문_상세_조회_Admin { + + @Test + void 주문_ID로_조회하면_주문_정보를_반환한다() { + Order created = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 2) + ))); + + Order order = orderService.findOrderById(created.getId()); + + assertThat(order.getId()).isEqualTo(created.getId()); + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("100000")); + } + + @Test + void 존재하지_않는_주문이면_예외() { + assertThatThrownBy(() -> orderService.findOrderById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + class 전체_주문_목록_조회_Admin { + + @Test + void 모든_사용자의_주문을_반환한다() { + orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + orderService.createOrder(OrderCommand.Create.of(2L, List.of( + OrderCommand.CreateItem.of(2L, "셔츠", new BigDecimal("30000"), 1) + ))); + orderService.createOrder(OrderCommand.Create.of(3L, List.of( + OrderCommand.CreateItem.of(3L, "바지", new BigDecimal("40000"), 1) + ))); + + Page result = orderService.findAllOrders(PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(3); + } + + @Test + void 페이징이_올바르게_동작한다() { + for (int i = 0; i < 5; i++) { + orderService.createOrder(OrderCommand.Create.of((long) (i + 1), List.of( + OrderCommand.CreateItem.of((long) (i + 1), "상품" + i, new BigDecimal("10000"), 1) + ))); + } + + Page result = orderService.findAllOrders(PageRequest.of(0, 2)); + + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.getTotalPages()).isEqualTo(3); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java new file mode 100644 index 000000000..138e74bde --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java @@ -0,0 +1,347 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.order.OrderRequest; +import com.loopers.application.product.ProductRequest; +import com.loopers.application.user.UserRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderAdminApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/orders"; + private static final String ORDER_USER_ENDPOINT = "/api/v1/orders"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String USER_ENDPOINT = "/api/v1/users"; + private static final String VALID_LDAP = "admin-ldap"; + private static final String USER_LOGIN_ID = "testuser"; + private static final String USER_PASSWORD = "Test1234!"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 주문_목록_조회_Admin { + + @Test + void 전체_주문을_최신순으로_페이징하여_200_응답한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + + placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId1, 1)))); + placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId2, 2)))); + + ResponseEntity>> response = + getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2), + () -> assertThat(response.getBody().data().page()).isEqualTo(0), + () -> assertThat(response.getBody().data().size()).isEqualTo(20), + () -> assertThat(response.getBody().data().content().get(0).createdAt()) + .isAfterOrEqualTo(response.getBody().data().content().get(1).createdAt()) + ); + } + + @Test + void 모든_사용자의_주문이_포함된다() { + signUp(); + signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + placeOrderAs("otheruser", "Other1234!", + new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); + + ResponseEntity>> response = + getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content()) + .extracting(OrderAdminV1Dto.OrderListResponse::userId) + .doesNotHaveDuplicates() + ); + } + + @Test + void 결과가_없으면_빈_목록을_반환한다() { + ResponseEntity>> response = + getList(""); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isZero() + ); + } + + @Test + void 요청_필드_규칙_위반_시_400_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=-1", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + + @Nested + class 주문_상세_조회_Admin { + + @Test + void 주문을_조회하면_200_응답과_주문_상세_정보를_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + + Long orderId = placeOrder(new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId1, 2), + new OrderRequest.PlaceItem(productId2, 1) + ))); + + ResponseEntity> response = getDetail(orderId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().userId()).isNotNull(), + () -> assertThat(response.getBody().data().totalAmount()).isEqualByComparingTo(new BigDecimal("130000")), + () -> assertThat(response.getBody().data().orderItems()).hasSize(2), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @Test + void 주문_상품은_스냅샷_정보로_반환한다() { + signUp(); + Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + Long orderId = placeOrder(new OrderRequest.Place(List.of( + new OrderRequest.PlaceItem(productId, 3) + ))); + + ResponseEntity> response = getDetail(orderId); + + OrderAdminV1Dto.OrderItemResponse item = response.getBody().data().orderItems().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo("운동화"), + () -> assertThat(item.price()).isEqualByComparingTo(new BigDecimal("50000")), + () -> assertThat(item.quantity()).isEqualTo(3), + () -> assertThat(item.orderPrice()).isEqualByComparingTo(new BigDecimal("150000")) + ); + } + + @Test + void 존재하지_않는_주문이면_404_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 주문입니다") + ); + } + + @Test + void 인증_헤더가_누락되면_401_응답() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증 헤더가 필요합니다") + ); + } + + @Test + void 인증에_실패하면_401_응답() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong-ldap"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/1", HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().message()).contains("인증에 실패했습니다") + ); + } + } + + // --- 헬퍼 메서드 --- + + private void signUp() { + signUp(USER_LOGIN_ID, USER_PASSWORD, "홍길동", "test@example.com"); + } + + private void signUp(String loginId, String password, String name, String email) { + UserRequest.SignUp request = new UserRequest.SignUp( + loginId, password, name, + LocalDate.of(2000, 1, 15), email + ); + testRestTemplate.exchange( + USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + BrandRequest.Register request = new BrandRequest.Register(name, description); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductRequest.Register request = new ProductRequest.Register( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long placeOrder(OrderRequest.Place request) { + ResponseEntity> response = testRestTemplate.exchange( + ORDER_USER_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, userHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void placeOrderAs(String loginId, String password, OrderRequest.Place request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + testRestTemplate.exchange( + ORDER_USER_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity> getDetail(Long orderId) { + return testRestTemplate.exchange( + ENDPOINT + "/" + orderId, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity>> getList(String queryString) { + return testRestTemplate.exchange( + ENDPOINT + queryString, HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_LDAP); + return headers; + } + + private HttpHeaders userHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", USER_LOGIN_ID); + headers.set("X-Loopers-LoginPw", USER_PASSWORD); + return headers; + } +} From 99521734559e637d82bfe24e30e011f4886880bc Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 02:13:47 +0900 Subject: [PATCH 45/68] =?UTF-8?q?docs:=20=EC=A3=BC=EB=AC=B8=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=EC=97=90=EC=84=9C=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=82=AD=EC=A0=9C=20=EB=A9=B1=EB=93=B1=EC=84=B1=20?= =?UTF-8?q?AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/order/class-diagram.md | 6 +++--- docs/requirements/order.md | 2 +- docs/specs/order/001-order-create.md | 3 +-- docs/specs/order/003-order-detail-user.md | 3 +-- docs/specs/order/005-order-detail-admin.md | 3 +-- docs/specs/product/003-product-delete.md | 3 ++- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/design/order/class-diagram.md b/docs/design/order/class-diagram.md index 66482b4de..98796d79d 100644 --- a/docs/design/order/class-diagram.md +++ b/docs/design/order/class-diagram.md @@ -11,16 +11,16 @@ classDiagram -Long userId -BigDecimal totalAmount -List~OrderItem~ orderItems - +create(userId, orderItems)$ Order + +create(userId)$ Order + +addItem(productId, productName, price, quantity) } class OrderItem { -Long productId -String productName - -String brandName -BigDecimal price -int quantity - +create(productId, productName, brandName, price, quantity)$ OrderItem + +create(productId, productName, price, quantity)$ OrderItem +getOrderPrice() BigDecimal } diff --git a/docs/requirements/order.md b/docs/requirements/order.md index 13dd803f5..7a2270f28 100644 --- a/docs/requirements/order.md +++ b/docs/requirements/order.md @@ -22,7 +22,7 @@ | 재고 충분성 | 주문 상품의 재고가 주문 수량 이상이어야 한다 | | 전체 실패 | 주문 상품 중 하나라도 재고가 부족하면 전체 주문이 실패한다 | | 재고 즉시 차감 | 주문 성공 시 재고를 즉시 차감한다 | -| 스냅샷 보존 | 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 스냅샷으로 저장한다 | +| 스냅샷 보존 | 주문 시점의 상품 정보(상품명, 가격)를 스냅샷으로 저장한다 | | 스냅샷 불변 | 원본 브랜드/상품이 수정/삭제되어도 주문 스냅샷은 영향받지 않는다 | | 주문 데이터 보존 | 주문 데이터는 삭제할 수 없다 | | 본인 주문만 조회 | 사용자는 본인의 주문만 조회할 수 있으며, 타인의 주문 접근 시 미존재로 응답한다 | diff --git a/docs/specs/order/001-order-create.md b/docs/specs/order/001-order-create.md index c2e6dd320..c87b1d0da 100644 --- a/docs/specs/order/001-order-create.md +++ b/docs/specs/order/001-order-create.md @@ -24,7 +24,6 @@ User | orderItems[] | Array | 주문 상품 목록 | | orderItems[].productId | Long | 상품 ID | | orderItems[].productName | String | 상품명 (스냅샷) | -| orderItems[].brandName | String | 브랜드명 (스냅샷) | | orderItems[].price | BigDecimal | 상품 가격 (스냅샷) | | orderItems[].quantity | Integer | 주문 수량 | | orderItems[].orderPrice | BigDecimal | 주문 금액 (price × quantity) | @@ -33,7 +32,7 @@ User ## 인수 조건 - [ ] 유효한 정보로 주문하면 200 응답과 생성된 주문 정보를 반환한다 - [ ] 주문 시 해당 상품의 재고가 주문 수량만큼 차감된다 -- [ ] 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 스냅샷으로 저장한다 +- [ ] 주문 시점의 상품 정보(상품명, 가격)를 스냅샷으로 저장한다 - [ ] 원본 상품이 수정/삭제되어도 주문 스냅샷은 영향받지 않는다 - [ ] totalAmount는 각 주문 상품의 orderPrice 합계이다 - [ ] 주문 상품 중 미존재 상품이 포함되면 404 응답, 메시지: "존재하지 않는 상품이 포함되어 있습니다" diff --git a/docs/specs/order/003-order-detail-user.md b/docs/specs/order/003-order-detail-user.md index b00355238..3adcd4e02 100644 --- a/docs/specs/order/003-order-detail-user.md +++ b/docs/specs/order/003-order-detail-user.md @@ -22,7 +22,6 @@ User | orderItems[] | Array | 주문 상품 목록 | | orderItems[].productId | Long | 상품 ID | | orderItems[].productName | String | 상품명 (스냅샷) | -| orderItems[].brandName | String | 브랜드명 (스냅샷) | | orderItems[].price | BigDecimal | 상품 가격 (스냅샷) | | orderItems[].quantity | Integer | 주문 수량 | | orderItems[].orderPrice | BigDecimal | 주문 금액 (price × quantity) | @@ -30,7 +29,7 @@ User ## 인수 조건 - [ ] 본인의 주문을 조회하면 200 응답과 주문 상세 정보를 반환한다 -- [ ] 주문 상품은 스냅샷 정보(상품명, 가격, 브랜드명)로 반환한다 +- [ ] 주문 상품은 스냅샷 정보(상품명, 가격)로 반환한다 - [ ] 해당 ID의 주문 데이터가 존재하지 않으면 404 응답, 메시지: "존재하지 않는 주문입니다" - [ ] 본인의 주문이 아니면 404 응답, 메시지: "존재하지 않는 주문입니다" - [ ] 인증 헤더가 누락되면 401 응답, 메시지: "인증 헤더가 필요합니다" diff --git a/docs/specs/order/005-order-detail-admin.md b/docs/specs/order/005-order-detail-admin.md index 042ddc354..5f3984b5a 100644 --- a/docs/specs/order/005-order-detail-admin.md +++ b/docs/specs/order/005-order-detail-admin.md @@ -23,7 +23,6 @@ Admin | orderItems[] | Array | 주문 상품 목록 | | orderItems[].productId | Long | 상품 ID | | orderItems[].productName | String | 상품명 (스냅샷) | -| orderItems[].brandName | String | 브랜드명 (스냅샷) | | orderItems[].price | BigDecimal | 상품 가격 (스냅샷) | | orderItems[].quantity | Integer | 주문 수량 | | orderItems[].orderPrice | BigDecimal | 주문 금액 (price × quantity) | @@ -31,7 +30,7 @@ Admin ## 인수 조건 - [ ] 주문을 조회하면 200 응답과 주문 상세 정보를 반환한다 -- [ ] 주문 상품은 스냅샷 정보(상품명, 가격, 브랜드명)로 반환한다 +- [ ] 주문 상품은 스냅샷 정보(상품명, 가격)로 반환한다 - [ ] 해당 ID의 주문 데이터가 존재하지 않으면 404 응답, 메시지: "존재하지 않는 주문입니다" - [ ] 인증 헤더가 누락되면 401 응답, 메시지: "인증 헤더가 필요합니다" - [ ] 인증에 실패하면 401 응답, 메시지: "인증에 실패했습니다" diff --git a/docs/specs/product/003-product-delete.md b/docs/specs/product/003-product-delete.md index 7bc7e703c..38b10b783 100644 --- a/docs/specs/product/003-product-delete.md +++ b/docs/specs/product/003-product-delete.md @@ -20,9 +20,10 @@ Admin ## 인수 조건 - [ ] 활성 상품을 삭제하면 200 응답한다 - [ ] 삭제된 상품은 삭제 상태로 변경된다 +- [ ] 이미 삭제된 상품을 다시 삭제해도 200 응답한다 - [ ] 상품이 미존재하면 404 응답, 메시지: "존재하지 않는 상품입니다" - [ ] 인증 헤더가 누락되면 401 응답, 메시지: "인증 헤더가 필요합니다" - [ ] 인증에 실패하면 401 응답, 메시지: "인증에 실패했습니다" ## 제약 -- 삭제된 상품도 미존재로 처리한다 +- 삭제 연산은 멱등하게 동작한다 — 이미 삭제된 상품을 다시 삭제해도 정상 응답한다 From 7e887b62d56036ed4c8284fa8a2b15ebff830a0a Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 02:15:42 +0900 Subject: [PATCH 46/68] =?UTF-8?q?refactor:=20ProductFacade=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductFacade에서 브랜드 ID 수집을 Set으로 변경하고 getBrandsMapByIds 활용 - Brand 단위 테스트에 경계값(100자, 500자) 성공 케이스 추가 - User 비밀번호 변경 통합 테스트 추가 (미존재 회원, 동일 비밀번호) - 주문 Admin 테스트 클래스명 한글화 (_Admin → _관리자) --- .../application/product/ProductFacade.java | 17 +++++++--------- .../order/OrderServiceIntegrationTest.java | 4 ++-- .../user/UserServiceIntegrationTest.java | 15 ++++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 20 +++++++++++++++++++ .../api/order/OrderAdminApiE2ETest.java | 4 ++-- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 57edfdf09..bf795d2f4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -68,13 +69,11 @@ public ProductInfo getActiveDetail(Long productId) { public Page getActiveList(@Valid ProductRequest.ListActive request) { Page products = productService.findActiveProducts(request.brandId(), request.toPageable()); - List brandIds = products.getContent().stream() + Set brandIds = products.getContent().stream() .map(Product::getBrandId) - .distinct() - .toList(); + .collect(Collectors.toSet()); - Map brandMap = brandService.getBrands(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, Function.identity())); + Map brandMap = brandService.getBrandsMapByIds(brandIds); return products.map(product -> { Brand brand = brandMap.get(product.getBrandId()); @@ -89,13 +88,11 @@ public Page getList(@Valid ProductRequest.ListAll request) { Page products = productService.findProducts( request.name(), request.brandId(), request.toDeleted(), request.toPageable()); - List brandIds = products.getContent().stream() + Set brandIds = products.getContent().stream() .map(Product::getBrandId) - .distinct() - .toList(); + .collect(Collectors.toSet()); - Map brandMap = brandService.getBrands(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, Function.identity())); + Map brandMap = brandService.getBrandsMapByIds(brandIds); return products.map(product -> { Brand brand = brandMap.get(product.getBrandId()); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index 571f31614..6fa8aa14f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -155,7 +155,7 @@ class 주문_목록_조회 { } @Nested - class 주문_상세_조회_Admin { + class 주문_상세_조회_관리자 { @Test void 주문_ID로_조회하면_주문_정보를_반환한다() { @@ -180,7 +180,7 @@ class 주문_상세_조회_Admin { } @Nested - class 전체_주문_목록_조회_Admin { + class 전체_주문_목록_조회_관리자 { @Test void 모든_사용자의_주문을_반환한다() { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index 18aab1755..8b8f32fde 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -117,6 +117,21 @@ class 비밀번호_변경 { assertThatThrownBy(() -> userService.authenticate(loginId, "Test1234!")) .isInstanceOf(CoreException.class); } + + @Test + void 존재하지_않는_회원이면_예외() { + assertThatThrownBy(() -> userService.changePassword(UserCommand.ChangePassword.of(999L, "NewPass123!"))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + void 현재_비밀번호와_동일하면_예외() { + User user = signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + + assertThatThrownBy(() -> userService.changePassword(UserCommand.ChangePassword.of(user.getId(), "Test1234!"))) + .isInstanceOf(CoreException.class); + } } @Nested diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index 281b289e5..10667e264 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -146,6 +146,16 @@ class 수정 { .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); } + @Test + void name이_100자이면_성공() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + String maxName = "a".repeat(100); + + brand.update(maxName, null); + + assertThat(brand.getName()).isEqualTo(maxName); + } + @Test void description이_500자를_초과하면_예외() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); @@ -156,6 +166,16 @@ class 수정 { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); } + + @Test + void description이_500자이면_성공() { + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + String maxDescription = "a".repeat(500); + + brand.update(null, maxDescription); + + assertThat(brand.getDescription()).isEqualTo(maxDescription); + } } @Nested diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java index 138e74bde..3ba3aee67 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java @@ -57,7 +57,7 @@ void tearDown() { } @Nested - class 주문_목록_조회_Admin { + class 주문_목록_조회_관리자 { @Test void 전체_주문을_최신순으로_페이징하여_200_응답한다() { @@ -162,7 +162,7 @@ class 주문_목록_조회_Admin { } @Nested - class 주문_상세_조회_Admin { + class 주문_상세_조회_관리자 { @Test void 주문을_조회하면_200_응답과_주문_상세_정보를_반환한다() { From 42369b1b678c1852f8031db08e2ce38de6094871 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 02:17:50 +0900 Subject: [PATCH 47/68] =?UTF-8?q?chore:=20DTO=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20Command=20=EC=A4=91=EC=B2=A9=20record=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=98=88=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/dto.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/rules/conventions/dto.md b/.claude/rules/conventions/dto.md index c51e52301..3b02beaaa 100644 --- a/.claude/rules/conventions/dto.md +++ b/.claude/rules/conventions/dto.md @@ -61,8 +61,8 @@ Command는 도메인별로 하나의 클래스에 중첩 record로 정의한다. Facade가 Request를 DB 조회 결과 등으로 보강하여 생성한다. ```java public record OrderCommand() { - public record Create(Long userId, List items) {} - public record CreateOrderItem(Long productId, String productName, BigDecimal price, int quantity) {} + public record Create(Long userId, List items) {} + public record CreateItem(Long productId, String productName, BigDecimal price, int quantity) {} public record Cancel(Long userId, Long orderId, String cancelReason) {} } ``` From 5eaac7f2ea2fc86f36c88d2bc904991d5f7ebe6f Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 02:25:44 +0900 Subject: [PATCH 48/68] =?UTF-8?q?refactor:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Command=20DTO=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/dto.md | 19 +++- .../application/brand/BrandCommand.java | 16 ++++ .../application/brand/BrandFacade.java | 6 +- .../application/brand/BrandService.java | 12 +-- .../brand/BrandRepositoryImpl.java | 8 +- .../brand/BrandServiceIntegrationTest.java | 96 +++++++++---------- 6 files changed, 94 insertions(+), 63 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java diff --git a/.claude/rules/conventions/dto.md b/.claude/rules/conventions/dto.md index 3b02beaaa..7edf4216a 100644 --- a/.claude/rules/conventions/dto.md +++ b/.claude/rules/conventions/dto.md @@ -59,11 +59,24 @@ public record OrderRequest() { Command는 도메인별로 하나의 클래스에 중첩 record로 정의한다. Facade가 Request를 DB 조회 결과 등으로 보강하여 생성한다. +`of(...)` 정적 팩토리 메서드로 생성한다. ```java public record OrderCommand() { - public record Create(Long userId, List items) {} - public record CreateItem(Long productId, String productName, BigDecimal price, int quantity) {} - public record Cancel(Long userId, Long orderId, String cancelReason) {} + public record Create(Long userId, List items) { + public static Create of(Long userId, List items) { + return new Create(userId, items); + } + } + public record CreateItem(Long productId, String productName, BigDecimal price, int quantity) { + public static CreateItem of(Long productId, String productName, BigDecimal price, int quantity) { + return new CreateItem(productId, productName, price, quantity); + } + } + public record Cancel(Long userId, Long orderId, String cancelReason) { + public static Cancel of(Long userId, Long orderId, String cancelReason) { + return new Cancel(userId, orderId, cancelReason); + } + } } ``` diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java new file mode 100644 index 000000000..37aba0733 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java @@ -0,0 +1,16 @@ +package com.loopers.application.brand; + +public record BrandCommand() { + + public record Create(String name, String description) { + public static Create of(String name, String description) { + return new Create(name, description); + } + } + + public record Update(String name, String description) { + public static Update of(String name, String description) { + return new Update(name, description); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7d0b82fbd..af104b1cd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -22,13 +22,15 @@ public class BrandFacade { @Transactional public BrandInfo register(@Valid BrandRequest.Register request) { - Brand brand = brandService.register(request.name(), request.description()); + BrandCommand.Create command = BrandCommand.Create.of(request.name(), request.description()); + Brand brand = brandService.register(command); return BrandInfo.from(brand); } @Transactional public BrandInfo update(Long brandId, @Valid BrandRequest.Update request) { - Brand brand = brandService.update(brandId, request.name(), request.description()); + BrandCommand.Update command = BrandCommand.Update.of(request.name(), request.description()); + Brand brand = brandService.update(brandId, command); return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 7f2f597a2..a37f220f1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -26,25 +26,25 @@ public class BrandService { // Command @Transactional - public Brand register(String name, String description) { - if (brandRepository.existsByName(name)) { + public Brand register(BrandCommand.Create command) { + if (brandRepository.existsByName(command.name())) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } - Brand brand = Brand.create(name, description); + Brand brand = Brand.create(command.name(), command.description()); return brandRepository.save(brand); } @Transactional - public Brand update(Long brandId, String name, String description) { + public Brand update(Long brandId, BrandCommand.Update command) { Brand brand = brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); - if (brandRepository.existsByNameAndIdNot(name, brand.getId())) { + if (brandRepository.existsByNameAndIdNot(command.name(), brand.getId())) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } - brand.update(name, description); + brand.update(command.name(), command.description()); return brand; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index af0477ca3..5f1b9a735 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -42,13 +42,13 @@ public boolean existsByNameAndIdNot(String name, Long id) { } @Override - public List findAllByIdIn(Collection ids) { - return brandJpaRepository.findAllByIdIn(ids); + public Page findAll(String name, Boolean deleted, Pageable pageable) { + return brandJpaRepository.findAll(name, deleted, pageable); } @Override - public Page findAll(String name, Boolean deleted, Pageable pageable) { - return brandJpaRepository.findAll(name, deleted, pageable); + public List findAllByIdIn(Collection ids) { + return brandJpaRepository.findAllByIdIn(ids); } @Override diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index 84949a8c5..793996ab3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -42,7 +42,7 @@ class 브랜드_등록 { @Test void 유효한_정보로_등록하면_브랜드가_생성된다() { - Brand result = brandService.register("나이키", "스포츠 브랜드"); + Brand result = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); assertThat(result.getId()).isNotNull(); assertThat(result.getName()).isEqualTo("나이키"); @@ -51,9 +51,9 @@ class 브랜드_등록 { @Test void 이미_존재하는_브랜드명으로_등록하면_예외() { - brandService.register("나이키", "스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - assertThatThrownBy(() -> brandService.register("나이키", "다른 설명")) + assertThatThrownBy(() -> brandService.register(BrandCommand.Create.of("나이키", "다른 설명"))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -61,11 +61,11 @@ class 브랜드_등록 { @Test void 삭제된_브랜드와_동일한_이름으로_등록하면_예외() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); - assertThatThrownBy(() -> brandService.register("나이키", "새 설명")) + assertThatThrownBy(() -> brandService.register(BrandCommand.Create.of("나이키", "새 설명"))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -77,9 +77,9 @@ class 브랜드_수정 { @Test void 유효한_정보로_수정하면_성공한다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand result = brandService.update(brand.getId(), "아디다스", "독일 스포츠 브랜드"); + Brand result = brandService.update(brand.getId(), BrandCommand.Update.of("아디다스", "독일 스포츠 브랜드")); assertThat(result.getName()).isEqualTo("아디다스"); assertThat(result.getDescription()).isEqualTo("독일 스포츠 브랜드"); @@ -87,7 +87,7 @@ class 브랜드_수정 { @Test void 미존재_브랜드면_예외() { - assertThatThrownBy(() -> brandService.update(999L, "나이키", null)) + assertThatThrownBy(() -> brandService.update(999L, BrandCommand.Update.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); @@ -95,10 +95,10 @@ class 브랜드_수정 { @Test void 삭제된_브랜드를_수정하면_예외() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); - assertThatThrownBy(() -> brandService.update(brand.getId(), "아디다스", null)) + assertThatThrownBy(() -> brandService.update(brand.getId(), BrandCommand.Update.of("아디다스", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); @@ -106,10 +106,10 @@ class 브랜드_수정 { @Test void 다른_브랜드와_이름이_중복이면_예외() { - brandService.register("나이키", "스포츠 브랜드"); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - assertThatThrownBy(() -> brandService.update(adidas.getId(), "나이키", null)) + assertThatThrownBy(() -> brandService.update(adidas.getId(), BrandCommand.Update.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -117,13 +117,13 @@ class 브랜드_수정 { @Test void 삭제된_브랜드와_이름이_중복이면_예외() { - Brand nike = brandService.register("나이키", "스포츠 브랜드"); + Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); nike.delete(); brandRepository.save(nike); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - assertThatThrownBy(() -> brandService.update(adidas.getId(), "나이키", null)) + assertThatThrownBy(() -> brandService.update(adidas.getId(), BrandCommand.Update.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -131,9 +131,9 @@ class 브랜드_수정 { @Test void 자기_자신_이름으로_수정하면_정상_처리된다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand result = brandService.update(brand.getId(), "나이키", "변경된 설명"); + Brand result = brandService.update(brand.getId(), BrandCommand.Update.of("나이키", "변경된 설명")); assertThat(result.getName()).isEqualTo("나이키"); assertThat(result.getDescription()).isEqualTo("변경된 설명"); @@ -146,7 +146,7 @@ class 브랜드_삭제 { @Test void 활성_브랜드를_삭제하면_삭제_상태로_변경된다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); @@ -156,7 +156,7 @@ class 브랜드_삭제 { @Test void 이미_삭제된_브랜드를_삭제해도_성공한다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); assertThatCode(() -> brandService.delete(brand.getId())) @@ -185,7 +185,7 @@ class 브랜드_조회 { @Test void 삭제된_브랜드도_조회된다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); @@ -201,9 +201,9 @@ class 브랜드_목록_조회 { @Test void 조건_없이_조회하면_전체_브랜드가_최신순으로_반환된다() { - brandService.register("나이키", "스포츠 브랜드"); - brandService.register("아디다스", "독일 스포츠 브랜드"); - brandService.register("뉴발란스", "미국 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Create.of("뉴발란스", "미국 스포츠 브랜드")); Page result = brandService.findBrands(null, null, PageRequest.of(0, 20)); @@ -215,9 +215,9 @@ class 브랜드_목록_조회 { @Test void name_키워드로_검색하면_부분_일치하는_브랜드만_반환된다() { - brandService.register("나이키 에어", "에어 시리즈"); - brandService.register("나이키 조던", "조던 시리즈"); - brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); + brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); + brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findBrands("나이키", null, PageRequest.of(0, 20)); @@ -228,8 +228,8 @@ class 브랜드_목록_조회 { @Test void deleted_false면_활성_브랜드만_반환된다() { - brandService.register("나이키", "스포츠 브랜드"); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -241,8 +241,8 @@ class 브랜드_목록_조회 { @Test void deleted_true면_삭제된_브랜드만_반환된다() { - brandService.register("나이키", "스포츠 브랜드"); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -254,11 +254,11 @@ class 브랜드_목록_조회 { @Test void 복합_조건_name과_deleted_적용_시_모두_반영된다() { - brandService.register("나이키 에어", "에어 시리즈"); - Brand deletedNike = brandService.register("나이키 조던", "조던 시리즈"); + brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); + Brand deletedNike = brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); deletedNike.delete(); brandRepository.save(deletedNike); - brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findBrands("나이키", false, PageRequest.of(0, 20)); @@ -280,9 +280,9 @@ class 브랜드_일괄_조회 { @Test void ID_목록에_해당하는_브랜드들이_반환된다() { - Brand nike = brandService.register("나이키", "스포츠 브랜드"); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); - brandService.register("뉴발란스", "미국 스포츠 브랜드"); + Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Create.of("뉴발란스", "미국 스포츠 브랜드")); List result = brandService.getBrands(List.of(nike.getId(), adidas.getId())); @@ -300,7 +300,7 @@ class 브랜드_일괄_조회 { @Test void 존재하지_않는_ID가_포함되면_존재하는_것만_반환된다() { - Brand nike = brandService.register("나이키", "스포츠 브랜드"); + Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); List result = brandService.getBrands(List.of(nike.getId(), 999L)); @@ -314,7 +314,7 @@ class 활성_브랜드_조회 { @Test void 활성_브랜드를_조회하면_성공한다() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); Brand result = brandService.getActiveBrand(brand.getId()); @@ -324,7 +324,7 @@ class 활성_브랜드_조회 { @Test void 삭제된_브랜드를_조회하면_예외() { - Brand brand = brandService.register("나이키", "스포츠 브랜드"); + Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); @@ -348,9 +348,9 @@ class 활성_브랜드_목록_조회 { @Test void 활성_브랜드만_이름_오름차순으로_반환된다() { - brandService.register("다나이키", "스포츠 브랜드"); - brandService.register("가아디다스", "독일 스포츠 브랜드"); - brandService.register("나뉴발란스", "미국 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("다나이키", "스포츠 브랜드")); + brandService.register(BrandCommand.Create.of("가아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Create.of("나뉴발란스", "미국 스포츠 브랜드")); Page result = brandService.findActiveBrands(null, PageRequest.of(0, 20)); @@ -362,8 +362,8 @@ class 활성_브랜드_목록_조회 { @Test void 삭제된_브랜드는_제외된다() { - brandService.register("나이키", "스포츠 브랜드"); - Brand adidas = brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -375,9 +375,9 @@ class 활성_브랜드_목록_조회 { @Test void name_키워드로_검색하면_활성_브랜드_중_부분_일치하는_것만_반환된다() { - brandService.register("나이키 에어", "에어 시리즈"); - brandService.register("나이키 조던", "조던 시리즈"); - brandService.register("아디다스", "독일 스포츠 브랜드"); + brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); + brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); + brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findActiveBrands("나이키", PageRequest.of(0, 20)); From 82aa40c1d0fb8b383fe7ea7ffbe8c62868549ef1 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 02:59:46 +0900 Subject: [PATCH 49/68] =?UTF-8?q?refactor:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Command=20DTO=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20En?= =?UTF-8?q?tity=20=EC=82=AD=EC=A0=9C=20=EA=B2=80=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductCommand.java | 18 +++ .../application/product/ProductFacade.java | 8 +- .../application/product/ProductService.java | 13 +-- .../com/loopers/domain/product/Product.java | 3 + .../product/ProductRepositoryImpl.java | 16 +-- .../ProductServiceIntegrationTest.java | 104 +++++++++--------- .../loopers/domain/product/ProductTest.java | 69 ++++++++++++ 7 files changed, 160 insertions(+), 71 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java new file mode 100644 index 000000000..f29b78416 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java @@ -0,0 +1,18 @@ +package com.loopers.application.product; + +import java.math.BigDecimal; + +public record ProductCommand() { + + public record Create(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + public static Create of(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + return new Create(brandId, name, price, stockQuantity, description); + } + } + + public record Update(String name, BigDecimal price, Integer stockQuantity, String description) { + public static Update of(String name, BigDecimal price, Integer stockQuantity, String description) { + return new Update(name, price, stockQuantity, description); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index bf795d2f4..10b4b54b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -32,17 +32,19 @@ public class ProductFacade { @Transactional public ProductInfo register(@Valid ProductRequest.Register request) { Brand brand = brandService.getActiveBrand(request.brandId()); - Product product = productService.register( + ProductCommand.Create command = ProductCommand.Create.of( request.brandId(), request.name(), request.price(), request.stockQuantity(), request.description()); + Product product = productService.register(command); return ProductInfo.from(product, brand.getName()); } @Transactional public ProductInfo update(Long productId, @Valid ProductRequest.Update request) { - Product product = productService.update( - productId, request.name(), request.price(), + ProductCommand.Update command = ProductCommand.Update.of( + request.name(), request.price(), request.stockQuantity(), request.description()); + Product product = productService.update(productId, command); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 02490635d..8a0dc0f2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -28,17 +27,18 @@ public class ProductService { // Command @Transactional - public Product register(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - Product product = Product.create(brandId, name, price, stockQuantity, description); + public Product register(ProductCommand.Create command) { + Product product = Product.create(command.brandId(), command.name(), command.price(), + command.stockQuantity(), command.description()); return productRepository.save(product); } @Transactional - public Product update(Long productId, String name, BigDecimal price, Integer stockQuantity, String description) { + public Product update(Long productId, ProductCommand.Update command) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); - product.update(name, price, stockQuantity, description); + product.update(command.name(), command.price(), command.stockQuantity(), command.description()); return product; } @@ -73,9 +73,6 @@ public List deductStocks(Map productQuantities) { } for (Product product : products) { - if (product.isDeleted()) { - throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다"); - } product.deductStock(productQuantities.get(product.getId())); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 45a799992..47297e808 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -60,16 +60,19 @@ public static Product create(Long brandId, String name, BigDecimal price, Intege } public void incrementLikeCount() { + validateNotDeleted(); this.likeCount++; } public void decrementLikeCount() { + validateNotDeleted(); if (this.likeCount > 0) { this.likeCount--; } } public void deductStock(int quantity) { + validateNotDeleted(); if (this.stockQuantity < quantity) { throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족한 상품이 있습니다"); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 3aa939a9b..cfeb6763f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -25,23 +25,23 @@ public Product save(Product product) { // Query @Override - public List findAllByIdIn(Collection ids) { - return productJpaRepository.findAllByIdIn(ids); + public Optional findById(Long id) { + return productJpaRepository.findById(id); } @Override - public List findAllByIdInForUpdate(List ids) { - return productJpaRepository.findAllByIdInForUpdate(ids); + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); } @Override - public Optional findById(Long id) { - return productJpaRepository.findById(id); + public List findAllByIdIn(Collection ids) { + return productJpaRepository.findAllByIdIn(ids); } @Override - public List findAllByBrandId(Long brandId) { - return productJpaRepository.findAllByBrandId(brandId); + public List findAllByIdInForUpdate(List ids) { + return productJpaRepository.findAllByIdInForUpdate(ids); } @Override diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index a5fc4b5b5..686111b14 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -48,7 +48,7 @@ class 상품_등록 { @Test void 유효한_정보로_등록하면_상품이_생성된다() { - Product result = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product result = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); assertThat(result.getId()).isNotNull(); assertThat(result.getBrandId()).isEqualTo(1L); @@ -65,9 +65,9 @@ class 상품_수정 { @Test void 유효한_정보로_수정하면_성공한다() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product result = productService.update(product.getId(), "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + Product result = productService.update(product.getId(), ProductCommand.Update.of("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); assertThat(result.getName()).isEqualTo("런닝화"); assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); @@ -77,7 +77,7 @@ class 상품_수정 { @Test void 미존재_상품이면_예외() { - assertThatThrownBy(() -> productService.update(999L, "런닝화", null, null, null)) + assertThatThrownBy(() -> productService.update(999L, ProductCommand.Update.of("런닝화", null, null, null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 상품입니다"); @@ -85,11 +85,11 @@ class 상품_수정 { @Test void 삭제된_상품을_수정하면_예외() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); - assertThatThrownBy(() -> productService.update(product.getId(), "런닝화", null, null, null)) + assertThatThrownBy(() -> productService.update(product.getId(), ProductCommand.Update.of("런닝화", null, null, null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 상품입니다"); @@ -101,7 +101,7 @@ class 상품_삭제 { @Test void 활성_상품을_삭제하면_삭제_상태로_변경된다() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); @@ -119,7 +119,7 @@ class 상품_삭제 { @Test void 이미_삭제된_상품을_다시_삭제해도_정상_처리된다() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); assertThatCode(() -> productService.delete(product.getId())) @@ -137,9 +137,9 @@ class 상품_목록_조회 { @Test void 조건_없이_조회하면_전체_상품을_최신_등록순으로_페이징하여_반환한다() { - productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); - productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); - productService.register(1L, "운동화C", new BigDecimal("30000"), 30, "설명C"); + productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Create.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); Page result = productService.findProducts(null, null, null, DEFAULT_PAGEABLE); @@ -152,8 +152,8 @@ class 상품_목록_조회 { @Test void 삭제된_상품도_포함하여_반환한다() { - productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -166,9 +166,9 @@ class 상품_목록_조회 { @Test void name_키워드로_검색하면_상품명에_해당_키워드가_포함된_상품만_반환한다() { - productService.register(1L, "런닝화", new BigDecimal("10000"), 10, "설명A"); - productService.register(1L, "운동화", new BigDecimal("20000"), 20, "설명B"); - productService.register(1L, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C"); + productService.register(ProductCommand.Create.of(1L, "런닝화", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Create.of(1L, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C")); Page result = productService.findProducts("런닝", null, null, DEFAULT_PAGEABLE); @@ -179,9 +179,9 @@ class 상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드에_속한_상품만_반환한다() { - productService.register(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); - productService.register(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); - productService.register(1L, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + productService.register(ProductCommand.Create.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Create.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); + productService.register(ProductCommand.Create.of(1L, "나이키 런닝화", new BigDecimal("30000"), 30, "설명")); Page result = productService.findProducts(null, 1L, null, DEFAULT_PAGEABLE); @@ -192,8 +192,8 @@ class 상품_목록_조회 { @Test void deleted_true로_필터링하면_삭제된_상품만_반환한다() { - productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -206,8 +206,8 @@ class 상품_목록_조회 { @Test void deleted_false로_필터링하면_활성_상품만_반환한다() { - productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Product deleted = productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); + productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -220,11 +220,11 @@ class 상품_목록_조회 { @Test void 복합_필터를_동시에_적용할_수_있다() { - productService.register(1L, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명"); - Product deleted = productService.register(1L, "나이키 조던", new BigDecimal("20000"), 20, "설명"); + productService.register(ProductCommand.Create.of(1L, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명")); + Product deleted = productService.register(ProductCommand.Create.of(1L, "나이키 조던", new BigDecimal("20000"), 20, "설명")); deleted.delete(); productRepository.save(deleted); - productService.register(2L, "나이키 콜라보", new BigDecimal("30000"), 30, "설명"); + productService.register(ProductCommand.Create.of(2L, "나이키 콜라보", new BigDecimal("30000"), 30, "설명")); Page result = productService.findProducts("나이키", 1L, false, DEFAULT_PAGEABLE); @@ -246,7 +246,7 @@ class 활성_상품_조회 { @Test void 활성_상품을_조회하면_성공한다() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); Product result = productService.getActiveProduct(product.getId()); @@ -256,7 +256,7 @@ class 활성_상품_조회 { @Test void 삭제된_상품을_조회하면_예외() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); @@ -280,9 +280,9 @@ class 활성_상품_목록_조회 { @Test void 조건_없이_조회하면_활성_상품만_최신순으로_반환한다() { - productService.register(1L, "운동화A", new BigDecimal("10000"), 10, "설명A"); - productService.register(1L, "운동화B", new BigDecimal("20000"), 20, "설명B"); - Product deleted = productService.register(1L, "운동화C", new BigDecimal("30000"), 30, "설명C"); + productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); deleted.delete(); productRepository.save(deleted); @@ -296,8 +296,8 @@ class 활성_상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드의_활성_상품만_반환한다() { - productService.register(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); - productService.register(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + productService.register(ProductCommand.Create.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Create.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); Page result = productService.findActiveProducts(1L, pageable); @@ -308,9 +308,9 @@ class 활성_상품_목록_조회 { @Test void 가격_오름차순으로_정렬할_수_있다() { - productService.register(1L, "비싼 운동화", new BigDecimal("90000"), 10, "설명"); - productService.register(1L, "싼 운동화", new BigDecimal("10000"), 10, "설명"); - productService.register(1L, "중간 운동화", new BigDecimal("50000"), 10, "설명"); + productService.register(ProductCommand.Create.of(1L, "비싼 운동화", new BigDecimal("90000"), 10, "설명")); + productService.register(ProductCommand.Create.of(1L, "싼 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Create.of(1L, "중간 운동화", new BigDecimal("50000"), 10, "설명")); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "price")); Page result = productService.findActiveProducts(null, pageable); @@ -321,9 +321,9 @@ class 활성_상품_목록_조회 { @Test void 좋아요_내림차순으로_정렬할_수_있다() { - Product p1 = productService.register(1L, "인기 상품", new BigDecimal("10000"), 10, "설명"); - productService.register(1L, "보통 상품", new BigDecimal("20000"), 20, "설명"); - Product p3 = productService.register(1L, "최고 인기", new BigDecimal("30000"), 30, "설명"); + Product p1 = productService.register(ProductCommand.Create.of(1L, "인기 상품", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Create.of(1L, "보통 상품", new BigDecimal("20000"), 20, "설명")); + Product p3 = productService.register(ProductCommand.Create.of(1L, "최고 인기", new BigDecimal("30000"), 30, "설명")); p1.incrementLikeCount(); p1.incrementLikeCount(); productRepository.save(p1); @@ -354,8 +354,8 @@ class 브랜드별_상품_일괄_삭제 { @Test void 해당_브랜드의_활성_상품이_모두_삭제_상태로_변경된다() { - Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Product product2 = productService.register(1L, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Create.of(1L, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); productService.deleteAllByBrandId(1L); @@ -373,7 +373,7 @@ class 브랜드별_상품_일괄_삭제 { @Test void 이미_삭제된_상품도_삭제_상태를_유지한다() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); productService.deleteAllByBrandId(1L); @@ -384,8 +384,8 @@ class 브랜드별_상품_일괄_삭제 { @Test void 다른_브랜드의_상품은_영향받지_않는다() { - productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Product otherBrandProduct = productService.register(2L, "샌들", new BigDecimal("30000"), 50, "여름 샌들"); + productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product otherBrandProduct = productService.register(ProductCommand.Create.of(2L, "샌들", new BigDecimal("30000"), 50, "여름 샌들")); productService.deleteAllByBrandId(1L); @@ -399,8 +399,8 @@ class 재고_일괄_차감 { @Test void 유효한_상품에_재고를_차감하면_차감된다() { - Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Product product2 = productService.register(1L, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠"); + Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Create.of(1L, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠")); productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 5)); @@ -412,7 +412,7 @@ class 재고_일괄_차감 { @Test void 미존재_상품이_포함되면_예외() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 10, 999L, 5))) .isInstanceOf(CoreException.class) @@ -422,19 +422,19 @@ class 재고_일괄_차감 { @Test void 삭제된_상품이_포함되면_예외() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 10))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) - .hasMessageContaining("존재하지 않는 상품이 포함되어 있습니다"); + .hasMessageContaining("존재하지 않는 상품입니다"); } @Test void 재고가_부족한_상품이_있으면_예외() { - Product product = productService.register(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화"); + Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 11))) .isInstanceOf(CoreException.class) @@ -444,8 +444,8 @@ class 재고_일괄_차감 { @Test void 재고_부족_시_어떤_상품의_재고도_차감되지_않는다() { - Product product1 = productService.register(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Product product2 = productService.register(1L, "셔츠", new BigDecimal("30000"), 5, "멋진 셔츠"); + Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Create.of(1L, "셔츠", new BigDecimal("30000"), 5, "멋진 셔츠")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 10))) .isInstanceOf(CoreException.class); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 78cebe731..417158f12 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -336,6 +336,64 @@ class 수정 { } } + @Nested + class 좋아요_증가 { + + @Test + void 좋아요수가_1_증가한다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.incrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @Test + void 삭제된_상품이면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + + assertThatThrownBy(() -> product.incrementLikeCount()) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } + + @Nested + class 좋아요_감소 { + + @Test + void 좋아요수가_1_감소한다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.incrementLikeCount(); + + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @Test + void 좋아요수가_0이면_감소하지_않는다() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @Test + void 삭제된_상품이면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + + assertThatThrownBy(() -> product.decrementLikeCount()) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } + } + @Nested class 재고_차감 { @@ -366,6 +424,17 @@ class 재고_차감 { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("재고가 부족한 상품이 있습니다"); } + + @Test + void 삭제된_상품이면_예외() { + Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.delete(); + + assertThatThrownBy(() -> product.deductStock(10)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) + .hasMessageContaining("존재하지 않는 상품입니다"); + } } @Nested From 49ed5831aead140f8ea52d79f4630a1d3a935860 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 03:12:04 +0900 Subject: [PATCH 50/68] =?UTF-8?q?docs:=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=EB=AA=85=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EC=83=81=ED=92=88=20AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/order/sequences/001-order-create.md | 2 +- docs/specs/order/001-order-create.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/design/order/sequences/001-order-create.md b/docs/design/order/sequences/001-order-create.md index bda4422c5..110ca5962 100644 --- a/docs/design/order/sequences/001-order-create.md +++ b/docs/design/order/sequences/001-order-create.md @@ -41,5 +41,5 @@ sequenceDiagram - 재고 차감과 주문 생성은 같은 트랜잭션에서 처리한다 - ProductService.재고차감이 상품 조회+활성 검증+재고 검증+차감+스냅샷 반환을 캡슐화한다 (재고 부족 시 예외) - 주문 상품 중 하나라도 재고가 부족하면 전체 주문이 실패한다 (부분 성공 없음) -- 주문 시점의 상품명, 가격, 브랜드명을 OrderItem에 스냅샷으로 저장한다 +- 주문 시점의 상품명, 가격을 OrderItem에 스냅샷으로 저장한다 - Facade가 ProductService와 OrderService를 오케스트레이션한다 diff --git a/docs/specs/order/001-order-create.md b/docs/specs/order/001-order-create.md index c87b1d0da..bce888df6 100644 --- a/docs/specs/order/001-order-create.md +++ b/docs/specs/order/001-order-create.md @@ -36,6 +36,7 @@ User - [ ] 원본 상품이 수정/삭제되어도 주문 스냅샷은 영향받지 않는다 - [ ] totalAmount는 각 주문 상품의 orderPrice 합계이다 - [ ] 주문 상품 중 미존재 상품이 포함되면 404 응답, 메시지: "존재하지 않는 상품이 포함되어 있습니다" +- [ ] 주문 상품 중 삭제된 상품이 포함되면 404 응답, 메시지: "존재하지 않는 상품입니다" - [ ] 주문 상품 중 하나라도 재고가 부족하면 400 응답, 메시지: "재고가 부족한 상품이 있습니다" - [ ] 재고 부족 시 전체 주문이 실패하며, 어떤 상품의 재고도 차감되지 않는다 - [ ] 동일 productId가 중복으로 포함되면 400 응답, 메시지: "주문 상품이 중복되었습니다" From b6acf4491259c46efef49258437010f1a9965b96 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 03:13:19 +0900 Subject: [PATCH 51/68] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=A6=9D=EA=B0=90=20=EC=8B=9C=20=EB=B9=84=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=95=88=EC=A0=84=EC=84=B1=20=ED=99=95?= =?UTF-8?q?=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/product/ProductService.java | 4 ++-- .../java/com/loopers/domain/product/ProductRepository.java | 1 + .../loopers/infrastructure/product/ProductJpaRepository.java | 5 +++++ .../infrastructure/product/ProductRepositoryImpl.java | 5 +++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 8a0dc0f2e..472f2e4f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -51,14 +51,14 @@ public void delete(Long productId) { @Transactional public void incrementLikeCount(Long productId) { - Product product = productRepository.findById(productId) + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); product.incrementLikeCount(); } @Transactional public void decrementLikeCount(Long productId) { - Product product = productRepository.findById(productId) + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); product.decrementLikeCount(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 7eaa078f5..f180b5eab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -14,6 +14,7 @@ public interface ProductRepository { // Query Optional findById(Long id); + Optional findByIdForUpdate(Long id); List findAllByBrandId(Long brandId); List findAllByIdIn(Collection ids); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index be8b0f355..f12fec639 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -12,10 +12,15 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; public interface ProductJpaRepository extends JpaRepository { // Query + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + List findAllByIdIn(Collection ids); @Lock(LockModeType.PESSIMISTIC_WRITE) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index cfeb6763f..0c33a7a0b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -29,6 +29,11 @@ public Optional findById(Long id) { return productJpaRepository.findById(id); } + @Override + public Optional findByIdForUpdate(Long id) { + return productJpaRepository.findByIdForUpdate(id); + } + @Override public List findAllByBrandId(Long brandId) { return productJpaRepository.findAllByBrandId(brandId); From 8f069a791520373e8db821bbf81e50c8dc9f0d2d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 05:40:30 +0900 Subject: [PATCH 52/68] =?UTF-8?q?refactor:=20Facade=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=82=AC=EC=A0=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20+=20=EC=88=9C=EC=88=98=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 19 +++++++++++-- .../application/like/LikeProductInfo.java | 10 ++----- .../application/product/ProductFacade.java | 28 +++++++++---------- .../interfaces/api/like/LikeV1Dto.java | 10 ++----- 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index b7a9de75a..b66bcfa28 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -5,6 +5,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.like.Like; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -65,10 +67,23 @@ public Page getLikedProducts(Long userId, @Valid LikeRequest.Li Map brandMap = brandService.getBrandsMapByIds(brandIds); + for (Like like : likes.getContent()) { + if (!productMap.containsKey(like.getProductId())) { + throw new CoreException(ErrorType.NOT_FOUND, + "상품 매핑 누락. likeId=" + like.getId() + ", productId=" + like.getProductId()); + } + } + + for (Product product : productMap.values()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드 매핑 누락. productId=" + product.getId() + ", brandId=" + product.getBrandId()); + } + } + return likes.map(like -> { Product product = productMap.get(like.getProductId()); - Brand brand = brandMap.get(product.getBrandId()); - return LikeProductInfo.from(product, brand.getName()); + return LikeProductInfo.from(product, brandMap.get(product.getBrandId()).getName()); }); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java index 3f5727c23..8a8b5721b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductInfo.java @@ -11,11 +11,8 @@ public record LikeProductInfo( String brandName, String name, BigDecimal price, - Integer stockQuantity, - String description, Integer likeCount, - LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime createdAt ) { public static LikeProductInfo from(Product product, String brandName) { @@ -25,11 +22,8 @@ public static LikeProductInfo from(Product product, String brandName) { brandName, product.getName(), product.getPrice(), - product.getStockQuantity(), - product.getDescription(), product.getLikeCount(), - product.getCreatedAt().toLocalDateTime(), - product.getUpdatedAt().toLocalDateTime() + product.getCreatedAt().toLocalDateTime() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 10b4b54b9..b4859edec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -12,10 +12,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; -import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; @Component @@ -77,13 +75,14 @@ public Page getActiveList(@Valid ProductRequest.ListActive request) Map brandMap = brandService.getBrandsMapByIds(brandIds); - return products.map(product -> { - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다"); + for (Product product : products.getContent()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드 매핑 누락. productId=" + product.getId() + ", brandId=" + product.getBrandId()); } - return ProductInfo.from(product, brand.getName()); - }); + } + + return products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); } public Page getList(@Valid ProductRequest.ListAll request) { @@ -96,12 +95,13 @@ public Page getList(@Valid ProductRequest.ListAll request) { Map brandMap = brandService.getBrandsMapByIds(brandIds); - return products.map(product -> { - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다"); + for (Product product : products.getContent()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드 매핑 누락. productId=" + product.getId() + ", brandId=" + product.getBrandId()); } - return ProductInfo.from(product, brand.getName()); - }); + } + + return products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 1a2145257..4ea9dc630 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -15,11 +15,8 @@ public record LikeProductResponse( String brandName, String name, BigDecimal price, - Integer stockQuantity, - String description, Integer likeCount, - LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime createdAt ) { public static LikeProductResponse from(LikeProductInfo info) { return new LikeProductResponse( @@ -28,11 +25,8 @@ public static LikeProductResponse from(LikeProductInfo info) { info.brandName(), info.name(), info.price(), - info.stockQuantity(), - info.description(), info.likeCount(), - info.createdAt(), - info.updatedAt() + info.createdAt() ); } } From 762a76435fb46033db51c298343fb416d0bf1139 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 05:41:51 +0900 Subject: [PATCH 53/68] =?UTF-8?q?refactor:=20Active=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20deletedAt=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/brand/BrandService.java | 4 +--- .../java/com/loopers/application/product/ProductService.java | 4 +--- .../main/java/com/loopers/domain/brand/BrandRepository.java | 2 ++ .../java/com/loopers/domain/product/ProductRepository.java | 1 + .../com/loopers/infrastructure/brand/BrandJpaRepository.java | 4 ++++ .../loopers/infrastructure/brand/BrandRepositoryImpl.java | 5 +++++ .../loopers/infrastructure/product/ProductJpaRepository.java | 3 +++ .../infrastructure/product/ProductRepositoryImpl.java | 5 +++++ 8 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index a37f220f1..5fd921c0f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -67,10 +67,8 @@ public Page findBrands(String name, Boolean deleted, Pageable pageable) { } public Brand getActiveBrand(Long brandId) { - Brand brand = brandRepository.findById(brandId) + return brandRepository.findActiveById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); - brand.validateNotDeleted(); - return brand; } public List getBrands(List brandIds) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 472f2e4f3..436a65286 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -93,10 +93,8 @@ public Product getProduct(Long productId) { } public Product getActiveProduct(Long productId) { - Product product = productRepository.findById(productId) + return productRepository.findActiveById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); - product.validateNotDeleted(); - return product; } public Page findProducts(String name, Long brandId, Boolean deleted, Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index ff2ff7ff9..55577a502 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -17,6 +17,8 @@ public interface BrandRepository { Optional findById(Long id); + Optional findActiveById(Long id); + boolean existsByName(String name); boolean existsByNameAndIdNot(String name, Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index f180b5eab..ab3a9a8d9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -14,6 +14,7 @@ public interface ProductRepository { // Query Optional findById(Long id); + Optional findActiveById(Long id); Optional findByIdForUpdate(Long id); List findAllByBrandId(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index fb299caba..7dc9c93a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -9,11 +9,15 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; public interface BrandJpaRepository extends JpaRepository { // Query + @Query("SELECT b FROM Brand b WHERE b.id = :id AND b.deletedAt IS NULL") + Optional findActiveById(@Param("id") Long id); + boolean existsByName(String name); boolean existsByNameAndIdNot(String name, Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 5f1b9a735..fa42e1b3a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -31,6 +31,11 @@ public Optional findById(Long id) { return brandJpaRepository.findById(id); } + @Override + public Optional findActiveById(Long id) { + return brandJpaRepository.findActiveById(id); + } + @Override public boolean existsByName(String name) { return brandJpaRepository.existsByName(name); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index f12fec639..a508b8430 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -17,6 +17,9 @@ public interface ProductJpaRepository extends JpaRepository { // Query + @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findActiveById(@Param("id") Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional findByIdForUpdate(@Param("id") Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 0c33a7a0b..5ca76750d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -29,6 +29,11 @@ public Optional findById(Long id) { return productJpaRepository.findById(id); } + @Override + public Optional findActiveById(Long id) { + return productJpaRepository.findActiveById(id); + } + @Override public Optional findByIdForUpdate(Long id) { return productJpaRepository.findByIdForUpdate(id); From e45030c6d4ac2bbeb574415e020a12e001589234 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 06:10:03 +0900 Subject: [PATCH 54/68] =?UTF-8?q?refactor:=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B3=B5=ED=86=B5=20=ED=94=BD=EC=8A=A4=EC=B2=98(E2?= =?UTF-8?q?ETestFixture)=20=EC=B6=94=EC=B6=9C=EB=A1=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/brand/BrandAdminApiE2ETest.java | 153 ++++++-------- .../interfaces/api/brand/BrandApiE2ETest.java | 55 ++--- .../interfaces/api/like/LikeApiE2ETest.java | 156 +++++--------- .../api/order/OrderAdminApiE2ETest.java | 111 +++------- .../interfaces/api/order/OrderApiE2ETest.java | 178 ++++++---------- .../api/product/ProductAdminApiE2ETest.java | 190 +++++++----------- .../api/product/ProductUserApiE2ETest.java | 136 ++++--------- .../interfaces/api/user/UserApiE2ETest.java | 48 ++--- .../com/loopers/support/E2ETestFixture.java | 99 +++++++++ 9 files changed, 453 insertions(+), 673 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index fc8071006..7c784517b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -1,10 +1,10 @@ package com.loopers.interfaces.api.brand; import com.loopers.application.brand.BrandRequest; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -31,8 +31,6 @@ class BrandAdminApiE2ETest { private static final String ENDPOINT = "/api-admin/v1/brands"; - private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; - private static final String VALID_LDAP = "admin-ldap"; @Autowired private TestRestTemplate testRestTemplate; @@ -40,6 +38,9 @@ class BrandAdminApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -73,7 +74,7 @@ class 브랜드_등록 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(duplicateRequest, adminHeaders()), + new HttpEntity<>(duplicateRequest, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -86,7 +87,7 @@ class 브랜드_등록 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -124,12 +125,12 @@ class 브랜드_등록 { @Test void 삭제된_브랜드와_동일한_이름으로_등록하면_409_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(brandId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(brandId); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(new BrandRequest.Register("나이키", "다른 설명"), adminHeaders()), + new HttpEntity<>(new BrandRequest.Register("나이키", "다른 설명"), fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -142,7 +143,7 @@ class 브랜드_수정 { @Test void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update("아디다스", "독일 스포츠 브랜드"); ResponseEntity> response = patchUpdate(brandId, request); @@ -156,7 +157,7 @@ class 브랜드_수정 { @Test void name만_보내면_name만_수정된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update("아디다스", null); ResponseEntity> response = patchUpdate(brandId, request); @@ -170,7 +171,7 @@ class 브랜드_수정 { @Test void description만_보내면_description만_수정된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update(null, "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -184,13 +185,13 @@ class 브랜드_수정 { @Test void 중복_브랜드명이면_409_응답() { - registerBrand("나이키", "스포츠 브랜드"); - Long adidasId = registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -203,7 +204,7 @@ class 브랜드_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -212,12 +213,12 @@ class 브랜드_수정 { @Test void 입력_규칙_위반_시_400_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update("", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -255,13 +256,13 @@ class 브랜드_수정 { @Test void 삭제된_브랜드와_동일한_이름으로_변경하면_409_응답() { - Long nikeId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(nikeId); - Long adidasId = registerBrand("아디다스", "독일 스포츠 브랜드"); + Long nikeId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(nikeId); + Long adidasId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, - new HttpEntity<>(new BrandRequest.Update("나이키", null), adminHeaders()), + new HttpEntity<>(new BrandRequest.Update("나이키", null), fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -270,7 +271,7 @@ class 브랜드_수정 { @Test void 자기_자신의_현재_이름과_동일한_이름으로_수정하면_200_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); BrandRequest.Update request = new BrandRequest.Update("나이키", "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -284,12 +285,12 @@ class 브랜드_수정 { @Test void 삭제된_브랜드를_수정하면_404_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(brandId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(brandId); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, - new HttpEntity<>(new BrandRequest.Update("변경이름", null), adminHeaders()), + new HttpEntity<>(new BrandRequest.Update("변경이름", null), fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -302,7 +303,7 @@ class 브랜드_삭제 { @Test void 활성_브랜드를_삭제하면_200_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); ResponseEntity> response = deleteRequest(brandId); @@ -311,7 +312,7 @@ class 브랜드_삭제 { @Test void 이미_삭제된_브랜드를_다시_삭제해도_200_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); deleteRequest(brandId); ResponseEntity> response = deleteRequest(brandId); @@ -353,9 +354,9 @@ class 브랜드_삭제 { @Test void 브랜드_삭제_시_해당_브랜드의_활성_상품도_삭제_상태로_변경된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - registerProduct(brandId, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.registerProduct(brandId, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); deleteRequest(brandId); @@ -376,9 +377,9 @@ class 브랜드_목록_조회 { @Test void 조건_없이_조회하면_전체_브랜드를_최신_등록순으로_페이징하여_200_응답한다() { - registerBrand("나이키", "스포츠 브랜드"); - registerBrand("아디다스", "독일 스포츠 브랜드"); - registerBrand("뉴발란스", "미국 스포츠 브랜드"); + fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("뉴발란스", "미국 스포츠 브랜드"); ResponseEntity>> response = getList(""); @@ -396,9 +397,9 @@ class 브랜드_목록_조회 { @Test void 삭제된_브랜드도_포함하여_반환한다() { - registerBrand("나이키", "스포츠 브랜드"); - Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); - deleteBrand(deletedBrandId); + fixture.registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.deleteBrand(deletedBrandId); ResponseEntity>> response = getList(""); @@ -413,9 +414,9 @@ class 브랜드_목록_조회 { @Test void name_키워드로_검색하면_해당_키워드가_포함된_브랜드만_반환한다() { - registerBrand("나이키", "스포츠 브랜드"); - registerBrand("아디다스", "독일 스포츠 브랜드"); - registerBrand("뉴발란스", "미국 스포츠 브랜드"); + fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("뉴발란스", "미국 스포츠 브랜드"); ResponseEntity>> response = getList("?name=나이키"); @@ -429,9 +430,9 @@ class 브랜드_목록_조회 { @Test void status_ACTIVE로_필터링하면_활성_브랜드만_반환한다() { - registerBrand("나이키", "스포츠 브랜드"); - Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); - deleteBrand(deletedBrandId); + fixture.registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.deleteBrand(deletedBrandId); ResponseEntity>> response = getList("?status=ACTIVE"); @@ -446,9 +447,9 @@ class 브랜드_목록_조회 { @Test void status_DELETED로_필터링하면_삭제된_브랜드만_반환한다() { - registerBrand("나이키", "스포츠 브랜드"); - Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); - deleteBrand(deletedBrandId); + fixture.registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.deleteBrand(deletedBrandId); ResponseEntity>> response = getList("?status=DELETED"); @@ -465,7 +466,7 @@ class 브랜드_목록_조회 { void status에_유효하지_않은_값을_보내면_400_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?status=INVALID", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -474,10 +475,10 @@ class 브랜드_목록_조회 { @Test void name_검색과_status_필터를_동시에_적용할_수_있다() { - registerBrand("나이키 에어", "에어 시리즈"); - Long deletedId = registerBrand("나이키 조던", "조던 시리즈"); - deleteBrand(deletedId); - registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("나이키 에어", "에어 시리즈"); + Long deletedId = fixture.registerBrand("나이키 조던", "조던 시리즈"); + fixture.deleteBrand(deletedId); + fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); ResponseEntity>> response = getList("?name=나이키&status=ACTIVE"); @@ -530,7 +531,7 @@ class 브랜드_목록_조회 { void 요청_필드_규칙_위반_시_400_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?page=-1", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -543,7 +544,7 @@ class 브랜드_상세_조회 { @Test void 활성_브랜드를_조회하면_200_응답과_상세_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); ResponseEntity> response = getDetail(brandId); @@ -561,8 +562,8 @@ class 브랜드_상세_조회 { @Test void 삭제된_브랜드도_조회할_수_있으며_status가_DELETED로_표시된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(brandId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(brandId); ResponseEntity> response = getDetail(brandId); @@ -577,7 +578,7 @@ class 브랜드_상세_조회 { void 미존재_브랜드면_404_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -612,20 +613,10 @@ class 브랜드_상세_조회 { // --- 헬퍼 메서드 --- - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = postRegister(request); - return response.getBody().data().id(); - } - - private void deleteBrand(Long brandId) { - deleteRequest(brandId); - } - private ResponseEntity> postRegister(BrandRequest.Register request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -633,7 +624,7 @@ private ResponseEntity> postRegister( private ResponseEntity> patchUpdate(Long brandId, BrandRequest.Update request) { return testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -641,7 +632,7 @@ private ResponseEntity> patchUpdate(L private ResponseEntity> deleteRequest(Long brandId) { return testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -649,7 +640,7 @@ private ResponseEntity> deleteRequest(Long brandId) { private ResponseEntity>> getList(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -657,34 +648,16 @@ private ResponseEntity>> private ResponseEntity> getDetail(Long brandId) { return testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = testRestTemplate.exchange( - PRODUCT_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - private ResponseEntity>> getProductList(String queryString) { return testRestTemplate.exchange( - PRODUCT_ENDPOINT + queryString, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + "/api-admin/v1/products" + queryString, HttpMethod.GET, + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } - - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java index f998b238a..4a370e30f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -27,8 +27,6 @@ class BrandApiE2ETest { private static final String ENDPOINT = "/api/v1/brands"; - private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; - private static final String VALID_LDAP = "admin-ldap"; @Autowired private TestRestTemplate testRestTemplate; @@ -36,6 +34,9 @@ class BrandApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -46,9 +47,9 @@ class 브랜드_목록_조회 { @Test void 조건_없이_조회하면_활성_브랜드만_이름_오름차순으로_페이징하여_200_응답한다() { - registerBrand("다나이키", "스포츠 브랜드"); - registerBrand("가아디다스", "독일 스포츠 브랜드"); - registerBrand("나뉴발란스", "미국 스포츠 브랜드"); + fixture.registerBrand("다나이키", "스포츠 브랜드"); + fixture.registerBrand("가아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("나뉴발란스", "미국 스포츠 브랜드"); ResponseEntity>> response = getList(""); @@ -66,9 +67,9 @@ class 브랜드_목록_조회 { @Test void 삭제된_브랜드는_반환하지_않는다() { - registerBrand("나이키", "스포츠 브랜드"); - Long deletedBrandId = registerBrand("아디다스", "독일 스포츠 브랜드"); - deleteBrand(deletedBrandId); + fixture.registerBrand("나이키", "스포츠 브랜드"); + Long deletedBrandId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.deleteBrand(deletedBrandId); ResponseEntity>> response = getList(""); @@ -81,9 +82,9 @@ class 브랜드_목록_조회 { @Test void name_키워드로_검색하면_해당_키워드가_포함된_활성_브랜드만_반환한다() { - registerBrand("나이키", "스포츠 브랜드"); - registerBrand("아디다스", "독일 스포츠 브랜드"); - registerBrand("뉴발란스", "미국 스포츠 브랜드"); + fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); + fixture.registerBrand("뉴발란스", "미국 스포츠 브랜드"); ResponseEntity>> response = getList("?name=나이키"); @@ -124,7 +125,7 @@ class 브랜드_상세_조회 { @Test void 활성_브랜드를_조회하면_200_응답과_브랜드_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); ResponseEntity> response = getDetail(brandId); @@ -140,8 +141,8 @@ class 브랜드_상세_조회 { @Test void 삭제된_브랜드를_조회하면_404_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(brandId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(brandId); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.GET, @@ -166,24 +167,6 @@ class 브랜드_상세_조회 { // --- 헬퍼 메서드 --- - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private void deleteBrand(Long brandId) { - testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference>() {} - ); - } - private ResponseEntity>> getList(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, HttpMethod.GET, @@ -199,10 +182,4 @@ private ResponseEntity> getDetail(Long bra new ParameterizedTypeReference<>() {} ); } - - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 57c59c10e..2a1818abd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -1,13 +1,9 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.brand.BrandRequest; -import com.loopers.application.product.ProductRequest; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.product.ProductAdminV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -25,7 +21,6 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; -import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -36,10 +31,6 @@ class LikeApiE2ETest { private static final String LIKE_ENDPOINT = "/api/v1/products/{productId}/likes"; private static final String LIKE_LIST_ENDPOINT = "/api/v1/likes"; - private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; - private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; - private static final String USER_ENDPOINT = "/api/v1/users"; - private static final String VALID_LDAP = "admin-ldap"; private static final String LOGIN_ID = "testuser"; private static final String LOGIN_PW = "Test1234!"; @@ -50,6 +41,9 @@ class LikeApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -60,9 +54,9 @@ class 좋아요_등록 { @Test void 활성_상품에_좋아요를_등록하면_200_응답() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = postLike(productId); @@ -71,9 +65,9 @@ class 좋아요_등록 { @Test void 좋아요_등록_시_해당_상품의_좋아요_수가_1_증가한다() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = postLike(productId); @@ -85,9 +79,9 @@ class 좋아요_등록 { @Test void 이미_좋아요한_상품에_재요청하면_200_응답하고_좋아요_수가_변동되지_않는다() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postLike(productId); ResponseEntity> response = postLike(productId); @@ -102,10 +96,10 @@ class 좋아요_등록 { @Test void 삭제된_상품에_좋아요_등록하면_404_응답() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ResponseEntity> response = testRestTemplate.exchange( LIKE_ENDPOINT, HttpMethod.POST, @@ -122,7 +116,7 @@ class 좋아요_등록 { @Test void 미존재_상품에_좋아요_등록하면_404_응답() { - signUpUser(); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( LIKE_ENDPOINT, HttpMethod.POST, @@ -177,9 +171,9 @@ class 좋아요_취소 { @Test void 좋아요한_상품의_좋아요를_취소하면_200_응답() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postLike(productId); ResponseEntity> response = deleteLike(productId); @@ -189,9 +183,9 @@ class 좋아요_취소 { @Test void 좋아요_취소_시_해당_상품의_좋아요_수가_1_감소한다() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postLike(productId); ResponseEntity> response = deleteLike(productId); @@ -204,9 +198,9 @@ class 좋아요_취소 { @Test void 좋아요하지_않은_상품에_취소_요청하면_200_응답하고_좋아요_수가_변동되지_않는다() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = deleteLike(productId); @@ -220,10 +214,10 @@ class 좋아요_취소 { @Test void 삭제된_상품에_좋아요_취소하면_404_응답() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ResponseEntity> response = testRestTemplate.exchange( LIKE_ENDPOINT, HttpMethod.DELETE, @@ -240,7 +234,7 @@ class 좋아요_취소 { @Test void 미존재_상품에_좋아요_취소하면_404_응답() { - signUpUser(); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( LIKE_ENDPOINT, HttpMethod.DELETE, @@ -295,10 +289,10 @@ class 좋아요_목록_조회 { @Test void 좋아요한_상품_목록을_좋아요_등록순으로_페이징하여_200_응답() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); postLike(productId1); postLike(productId2); @@ -316,13 +310,13 @@ class 좋아요_목록_조회 { @Test void 활성_상품만_반환한다() { - signUpUser(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "슬리퍼", new BigDecimal("30000"), 50, "편한 슬리퍼"); postLike(productId1); postLike(productId2); - deleteProduct(productId2); + fixture.deleteProduct(productId2); ResponseEntity>> response = getLikeList(""); @@ -336,7 +330,7 @@ class 좋아요_목록_조회 { @Test void 결과가_없으면_빈_목록을_반환한다() { - signUpUser(); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); ResponseEntity>> response = getLikeList(""); @@ -349,7 +343,7 @@ class 좋아요_목록_조회 { @Test void 요청_필드_규칙_위반_시_400_응답() { - signUpUser(); + fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( LIKE_LIST_ENDPOINT + "?page=-1", HttpMethod.GET, @@ -395,59 +389,18 @@ class 좋아요_목록_조회 { // --- 헬퍼 메서드 --- - private void signUpUser() { - UserRequest.SignUp request = new UserRequest.SignUp( - LOGIN_ID, LOGIN_PW, "홍길동", - LocalDate.of(2000, 1, 15), "test@example.com" - ); - testRestTemplate.exchange( - USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), - new ParameterizedTypeReference>() {} - ); - } - - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - BRAND_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = testRestTemplate.exchange( - PRODUCT_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private void deleteProduct(Long productId) { - testRestTemplate.exchange( - PRODUCT_ENDPOINT + "/" + productId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference>() {} - ); - } - - private ResponseEntity> deleteLike(Long productId) { + private ResponseEntity> postLike(Long productId) { return testRestTemplate.exchange( - LIKE_ENDPOINT, HttpMethod.DELETE, + LIKE_ENDPOINT, HttpMethod.POST, new HttpEntity<>(userHeaders()), new ParameterizedTypeReference<>() {}, productId ); } - private ResponseEntity> postLike(Long productId) { + private ResponseEntity> deleteLike(Long productId) { return testRestTemplate.exchange( - LIKE_ENDPOINT, HttpMethod.POST, + LIKE_ENDPOINT, HttpMethod.DELETE, new HttpEntity<>(userHeaders()), new ParameterizedTypeReference<>() {}, productId @@ -464,22 +417,13 @@ private ResponseEntity>> private ResponseEntity>> getProductList(String queryString) { return testRestTemplate.exchange( - PRODUCT_ENDPOINT + queryString, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + "/api-admin/v1/products" + queryString, HttpMethod.GET, + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } private HttpHeaders userHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", LOGIN_ID); - headers.set("X-Loopers-LoginPw", LOGIN_PW); - return headers; - } - - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; + return fixture.userHeaders(LOGIN_ID, LOGIN_PW); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java index 3ba3aee67..f55c465db 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java @@ -1,14 +1,9 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.brand.BrandRequest; import com.loopers.application.order.OrderRequest; -import com.loopers.application.product.ProductRequest; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; -import com.loopers.interfaces.api.product.ProductAdminV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -26,7 +21,6 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; -import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -37,11 +31,6 @@ class OrderAdminApiE2ETest { private static final String ENDPOINT = "/api-admin/v1/orders"; - private static final String ORDER_USER_ENDPOINT = "/api/v1/orders"; - private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; - private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; - private static final String USER_ENDPOINT = "/api/v1/users"; - private static final String VALID_LDAP = "admin-ldap"; private static final String USER_LOGIN_ID = "testuser"; private static final String USER_PASSWORD = "Test1234!"; @@ -51,6 +40,9 @@ class OrderAdminApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -61,10 +53,10 @@ class 주문_목록_조회_관리자 { @Test void 전체_주문을_최신순으로_페이징하여_200_응답한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId1, 1)))); placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId2, 2)))); @@ -85,10 +77,10 @@ class 주문_목록_조회_관리자 { @Test void 모든_사용자의_주문이_포함된다() { - signUp(); - signUp("otheruser", "Other1234!", "김철수", "other@example.com"); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + fixture.signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); placeOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); placeOrderAs("otheruser", "Other1234!", @@ -122,7 +114,7 @@ class 주문_목록_조회_관리자 { void 요청_필드_규칙_위반_시_400_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?page=-1", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -166,10 +158,10 @@ class 주문_상세_조회_관리자 { @Test void 주문을_조회하면_200_응답과_주문_상세_정보를_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); Long orderId = placeOrder(new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId1, 2), @@ -190,9 +182,9 @@ class 주문_상세_조회_관리자 { @Test void 주문_상품은_스냅샷_정보로_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); Long orderId = placeOrder(new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId, 3) @@ -215,7 +207,7 @@ class 주문_상세_조회_관리자 { void 존재하지_않는_주문이면_404_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -259,46 +251,9 @@ class 주문_상세_조회_관리자 { // --- 헬퍼 메서드 --- - private void signUp() { - signUp(USER_LOGIN_ID, USER_PASSWORD, "홍길동", "test@example.com"); - } - - private void signUp(String loginId, String password, String name, String email) { - UserRequest.SignUp request = new UserRequest.SignUp( - loginId, password, name, - LocalDate.of(2000, 1, 15), email - ); - testRestTemplate.exchange( - USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), - new ParameterizedTypeReference>() {} - ); - } - - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - BRAND_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = testRestTemplate.exchange( - PRODUCT_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - private Long placeOrder(OrderRequest.Place request) { ResponseEntity> response = testRestTemplate.exchange( - ORDER_USER_ENDPOINT, HttpMethod.POST, + "/api/v1/orders", HttpMethod.POST, new HttpEntity<>(request, userHeaders()), new ParameterizedTypeReference<>() {} ); @@ -306,12 +261,9 @@ private Long placeOrder(OrderRequest.Place request) { } private void placeOrderAs(String loginId, String password, OrderRequest.Place request) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", loginId); - headers.set("X-Loopers-LoginPw", password); testRestTemplate.exchange( - ORDER_USER_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, headers), + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, fixture.userHeaders(loginId, password)), new ParameterizedTypeReference>() {} ); } @@ -319,7 +271,7 @@ private void placeOrderAs(String loginId, String password, OrderRequest.Place re private ResponseEntity> getDetail(Long orderId) { return testRestTemplate.exchange( ENDPOINT + "/" + orderId, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -327,21 +279,12 @@ private ResponseEntity> getDetail(Lon private ResponseEntity>> getList(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } - private HttpHeaders userHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", USER_LOGIN_ID); - headers.set("X-Loopers-LoginPw", USER_PASSWORD); - return headers; + return fixture.userHeaders(USER_LOGIN_ID, USER_PASSWORD); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index 5cf7de996..dde025db1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -1,14 +1,10 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.brand.BrandRequest; import com.loopers.application.order.OrderRequest; -import com.loopers.application.product.ProductRequest; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.product.ProductAdminV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -37,10 +33,6 @@ class OrderApiE2ETest { private static final String ENDPOINT = "/api/v1/orders"; - private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; - private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; - private static final String USER_ENDPOINT = "/api/v1/users"; - private static final String VALID_LDAP = "admin-ldap"; private static final String USER_LOGIN_ID = "testuser"; private static final String USER_PASSWORD = "Test1234!"; @@ -50,6 +42,9 @@ class OrderApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -60,10 +55,10 @@ class 주문_요청 { @Test void 유효한_정보로_주문하면_200_응답과_생성된_주문_정보를_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId1, 2), @@ -83,9 +78,9 @@ class 주문_요청 { @Test void 주문_시_스냅샷_정보가_올바르게_저장된다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId, 3) @@ -106,9 +101,9 @@ class 주문_요청 { @Test void 주문_시_해당_상품의_재고가_주문_수량만큼_차감된다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId, 3) @@ -129,7 +124,7 @@ class 주문_요청 { @Test void 미존재_상품이_포함되면_404_응답() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(999L, 1) @@ -149,9 +144,9 @@ class 주문_요청 { @Test void 재고가_부족하면_400_응답() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 5, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 5, "편한 운동화"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId, 10) @@ -171,10 +166,10 @@ class 주문_요청 { @Test void 재고_부족_시_전체_주문이_실패하며_어떤_상품의_재고도_차감되지_않는다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 3, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 3, "멋진 셔츠"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId1, 2), @@ -207,9 +202,9 @@ class 주문_요청 { @Test void 동일_상품ID가_중복으로_포함되면_400_응답() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(productId, 1), @@ -230,7 +225,7 @@ class 주문_요청 { @Test void 요청_필드_규칙_위반_시_400_응답() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); OrderRequest.Place request = new OrderRequest.Place(List.of( new OrderRequest.PlaceItem(1L, 0) @@ -288,10 +283,10 @@ class 주문_목록_조회 { @Test void 조건_없이_조회하면_본인의_주문만_최신순으로_페이징하여_200_응답한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId1, 1)))); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId2, 2)))); @@ -310,10 +305,10 @@ class 주문_목록_조회 { @Test void 타인의_주문은_반환하지_않는다() { - signUp(); - signUp("otheruser", "Other1234!", "김철수", "other@example.com"); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + fixture.signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); postOrderAs("otheruser", "Other1234!", @@ -330,9 +325,9 @@ class 주문_목록_조회 { @Test void 시작일만_지정하면_해당일_이후_주문만_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); LocalDate today = LocalDate.now(); @@ -353,9 +348,9 @@ class 주문_목록_조회 { @Test void 종료일만_지정하면_해당일_이전_주문만_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); LocalDate today = LocalDate.now(); @@ -376,9 +371,9 @@ class 주문_목록_조회 { @Test void 시작일과_종료일을_모두_지정하면_해당_기간_내_주문만_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); postOrder(new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 1)))); LocalDate today = LocalDate.now(); @@ -398,7 +393,7 @@ class 주문_목록_조회 { @Test void 시작일이_종료일보다_미래이면_400_응답() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); LocalDate today = LocalDate.now(); @@ -417,7 +412,7 @@ class 주문_목록_조회 { @Test void 결과가_없으면_빈_목록을_반환한다() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); ResponseEntity>> response = getOrderList(""); @@ -431,7 +426,7 @@ class 주문_목록_조회 { @Test void 요청_필드_규칙_위반_시_400_응답() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?page=-1", @@ -480,10 +475,10 @@ class 주문_상세_조회 { @Test void 본인의_주문을_조회하면_200_응답과_주문_상세_정보를_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId1 = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - Long productId2 = registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId1 = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long productId2 = fixture.registerProduct(brandId, "셔츠", new BigDecimal("30000"), 100, "멋진 셔츠"); ResponseEntity> createResponse = postOrder( new OrderRequest.Place(List.of( @@ -506,9 +501,9 @@ class 주문_상세_조회 { @Test void 주문_상품은_스냅샷_정보로_반환한다() { - signUp(); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> createResponse = postOrder( new OrderRequest.Place(List.of(new OrderRequest.PlaceItem(productId, 3))) @@ -530,7 +525,7 @@ class 주문_상세_조회 { @Test void 존재하지_않는_주문이면_404_응답() { - signUp(); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", @@ -547,10 +542,10 @@ class 주문_상세_조회 { @Test void 본인의_주문이_아니면_404_응답() { - signUp(); - signUp("otheruser", "Other1234!", "김철수", "other@example.com"); - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); + fixture.signUp("otheruser", "Other1234!", "김철수", "other@example.com"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> createResponse = postOrderAs( "otheruser", "Other1234!", @@ -605,47 +600,10 @@ class 주문_상세_조회 { // --- 헬퍼 메서드 --- - private void signUp() { - signUp(USER_LOGIN_ID, USER_PASSWORD, "홍길동", "test@example.com"); - } - - private void signUp(String loginId, String password, String name, String email) { - UserRequest.SignUp request = new UserRequest.SignUp( - loginId, password, name, - LocalDate.of(2000, 1, 15), email - ); - testRestTemplate.exchange( - USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), - new ParameterizedTypeReference>() {} - ); - } - - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - BRAND_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = testRestTemplate.exchange( - PRODUCT_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - private List getProductList() { ResponseEntity>> response = testRestTemplate.exchange( - PRODUCT_ENDPOINT, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + "/api-admin/v1/products", HttpMethod.GET, + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().content(); @@ -661,12 +619,9 @@ private ResponseEntity> postOrder(OrderReq private ResponseEntity> postOrderAs( String loginId, String password, OrderRequest.Place request) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", loginId); - headers.set("X-Loopers-LoginPw", password); return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, headers), + new HttpEntity<>(request, fixture.userHeaders(loginId, password)), new ParameterizedTypeReference<>() {} ); } @@ -689,16 +644,7 @@ private ResponseEntity>> ); } - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } - private HttpHeaders userHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", USER_LOGIN_ID); - headers.set("X-Loopers-LoginPw", USER_PASSWORD); - return headers; + return fixture.userHeaders(USER_LOGIN_ID, USER_PASSWORD); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 219c1f942..e22bbd54f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,10 +1,9 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.brand.BrandRequest; import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -31,8 +30,6 @@ class ProductAdminApiE2ETest { private static final String ENDPOINT = "/api-admin/v1/products"; - private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; - private static final String VALID_LDAP = "admin-ldap"; @Autowired private TestRestTemplate testRestTemplate; @@ -40,6 +37,9 @@ class ProductAdminApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -50,7 +50,7 @@ class 상품_등록 { @Test void 유효한_정보로_등록하면_상품_정보가_반환된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); ProductRequest.Register request = new ProductRequest.Register( brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화" ); @@ -81,7 +81,7 @@ class 상품_등록 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -93,8 +93,8 @@ class 상품_등록 { @Test void 삭제된_브랜드면_404_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - deleteBrand(brandId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.deleteBrand(brandId); ProductRequest.Register request = new ProductRequest.Register( brandId, "운동화", new BigDecimal("50000"), 100, "설명" @@ -102,7 +102,7 @@ class 상품_등록 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -111,14 +111,14 @@ class 상품_등록 { @Test void 요청_필드_규칙_위반_시_400_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); ProductRequest.Register request = new ProductRequest.Register( brandId, "", new BigDecimal("50000"), 100, "설명" ); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -170,8 +170,8 @@ class 상품_수정 { @Test void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ProductRequest.Update request = new ProductRequest.Update( "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화" ); @@ -192,8 +192,8 @@ class 상품_수정 { @Test void 상품명만_보내면_상품명만_수정된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -211,8 +211,8 @@ class 상품_수정 { @Test void 소속_브랜드는_변경되지_않는다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null ); @@ -234,7 +234,7 @@ class 상품_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -246,9 +246,9 @@ class 상품_수정 { @Test void 삭제된_상품이면_404_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ProductRequest.Update request = new ProductRequest.Update( "런닝화", null, null, null @@ -256,7 +256,7 @@ class 상품_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -265,15 +265,15 @@ class 상품_수정 { @Test void 요청_필드_규칙_위반_시_400_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ProductRequest.Update request = new ProductRequest.Update( "", null, null, null ); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -325,8 +325,8 @@ class 상품_삭제 { @Test void 활성_상품을_삭제하면_200_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = deleteRequest(productId); @@ -337,7 +337,7 @@ class 상품_삭제 { void 미존재_상품이면_404_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -349,9 +349,9 @@ class 상품_삭제 { @Test void 이미_삭제된_상품을_다시_삭제해도_200_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ResponseEntity> response = deleteRequest(productId); @@ -395,8 +395,8 @@ class 상품_상세_조회 { @Test void 활성_상품을_조회하면_200_응답과_상품_상세_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = getDetail(productId); @@ -419,9 +419,9 @@ class 상품_상세_조회 { @Test void 삭제된_상품도_조회할_수_있으며_status가_DELETED로_표시된다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ResponseEntity> response = getDetail(productId); @@ -437,7 +437,7 @@ class 상품_상세_조회 { void 해당_ID의_상품_데이터가_존재하지_않으면_404_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -484,10 +484,10 @@ class 상품_목록_조회 { @Test void 조건_없이_조회하면_전체_상품을_최신_등록순으로_페이징하여_200_응답한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); ResponseEntity>> response = getList(""); @@ -505,10 +505,10 @@ class 상품_목록_조회 { @Test void 삭제된_상품도_포함하여_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - deleteProduct(deletedProductId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.deleteProduct(deletedProductId); ResponseEntity>> response = getList(""); @@ -523,10 +523,10 @@ class 상품_목록_조회 { @Test void name_키워드로_검색하면_상품명에_해당_키워드가_포함된_상품만_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "런닝화", new BigDecimal("10000"), 10, "설명A"); - registerProduct(brandId, "운동화", new BigDecimal("20000"), 20, "설명B"); - registerProduct(brandId, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "런닝화", new BigDecimal("10000"), 10, "설명A"); + fixture.registerProduct(brandId, "운동화", new BigDecimal("20000"), 20, "설명B"); + fixture.registerProduct(brandId, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C"); ResponseEntity>> response = getList("?name=런닝"); @@ -542,11 +542,11 @@ class 상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드에_속한_상품만_반환한다() { - Long nikeId = registerBrand("나이키", "스포츠 브랜드"); - Long adidasId = registerBrand("아디다스", "독일 브랜드"); - registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); - registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); - registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + Long nikeId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = fixture.registerBrand("아디다스", "독일 브랜드"); + fixture.registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + fixture.registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + fixture.registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); ResponseEntity>> response = getList("?brandId=" + nikeId); @@ -562,10 +562,10 @@ class 상품_목록_조회 { @Test void status_ACTIVE로_필터링하면_활성_상품만_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - deleteProduct(deletedProductId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.deleteProduct(deletedProductId); ResponseEntity>> response = getList("?status=ACTIVE"); @@ -580,10 +580,10 @@ class 상품_목록_조회 { @Test void status_DELETED로_필터링하면_삭제된_상품만_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - deleteProduct(deletedProductId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.deleteProduct(deletedProductId); ResponseEntity>> response = getList("?status=DELETED"); @@ -600,7 +600,7 @@ class 상품_목록_조회 { void status에_유효하지_않은_값을_보내면_400_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?status=INVALID", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -609,12 +609,12 @@ class 상품_목록_조회 { @Test void name_검색과_brandId_필터와_status_필터를_동시에_적용할_수_있다() { - Long nikeId = registerBrand("나이키", "스포츠 브랜드"); - Long adidasId = registerBrand("아디다스", "독일 브랜드"); - registerProduct(nikeId, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명"); - Long deletedId = registerProduct(nikeId, "나이키 조던", new BigDecimal("20000"), 20, "설명"); - deleteProduct(deletedId); - registerProduct(adidasId, "나이키 콜라보", new BigDecimal("30000"), 30, "설명"); + Long nikeId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = fixture.registerBrand("아디다스", "독일 브랜드"); + fixture.registerProduct(nikeId, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명"); + Long deletedId = fixture.registerProduct(nikeId, "나이키 조던", new BigDecimal("20000"), 20, "설명"); + fixture.deleteProduct(deletedId); + fixture.registerProduct(adidasId, "나이키 콜라보", new BigDecimal("30000"), 30, "설명"); ResponseEntity>> response = getList("?name=나이키&brandId=" + nikeId + "&status=ACTIVE"); @@ -642,7 +642,7 @@ class 상품_목록_조회 { void 요청_필드_규칙_위반_시_400_응답() { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "?page=-1", HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -683,44 +683,10 @@ class 상품_목록_조회 { // --- 헬퍼 메서드 --- - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - BRAND_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private void deleteBrand(Long brandId) { - testRestTemplate.exchange( - BRAND_ENDPOINT + "/" + brandId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference>() {} - ); - } - - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = postRegister(request); - return response.getBody().data().id(); - } - - private void deleteProduct(Long productId) { - testRestTemplate.exchange( - ENDPOINT + "/" + productId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference>() {} - ); - } - private ResponseEntity> deleteRequest(Long productId) { return testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -729,7 +695,7 @@ private ResponseEntity> postRegis ProductRequest.Register request) { return testRestTemplate.exchange( ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -738,7 +704,7 @@ private ResponseEntity> patchUpda Long productId, ProductRequest.Update request) { return testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.PATCH, - new HttpEntity<>(request, adminHeaders()), + new HttpEntity<>(request, fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -746,7 +712,7 @@ private ResponseEntity> patchUpda private ResponseEntity> getDetail(Long productId) { return testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } @@ -754,14 +720,8 @@ private ResponseEntity> getDetail private ResponseEntity>> getList(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, HttpMethod.GET, - new HttpEntity<>(adminHeaders()), + new HttpEntity<>(fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); } - - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java index 522762bc2..b65edbb1a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java @@ -1,12 +1,8 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.brand.BrandRequest; -import com.loopers.application.product.ProductRequest; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -24,7 +20,6 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; -import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -34,9 +29,6 @@ class ProductUserApiE2ETest { private static final String ENDPOINT = "/api/v1/products"; - private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; - private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; - private static final String VALID_LDAP = "admin-ldap"; @Autowired private TestRestTemplate testRestTemplate; @@ -44,6 +36,9 @@ class ProductUserApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -54,10 +49,10 @@ class 상품_목록_조회 { @Test void 조건_없이_조회하면_활성_상품만_최신순으로_페이징하여_200_응답한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); ResponseEntity>> response = getList(""); @@ -75,10 +70,10 @@ class 상품_목록_조회 { @Test void 삭제된_상품은_반환하지_않는다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - Long deletedProductId = registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - deleteProduct(deletedProductId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + Long deletedProductId = fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.deleteProduct(deletedProductId); ResponseEntity>> response = getList(""); @@ -91,11 +86,11 @@ class 상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드에_속한_활성_상품만_반환한다() { - Long nikeId = registerBrand("나이키", "스포츠 브랜드"); - Long adidasId = registerBrand("아디다스", "독일 브랜드"); - registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); - registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); - registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); + Long nikeId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long adidasId = fixture.registerBrand("아디다스", "독일 브랜드"); + fixture.registerProduct(nikeId, "나이키 운동화", new BigDecimal("10000"), 10, "설명"); + fixture.registerProduct(adidasId, "아디다스 운동화", new BigDecimal("20000"), 20, "설명"); + fixture.registerProduct(nikeId, "나이키 런닝화", new BigDecimal("30000"), 30, "설명"); ResponseEntity>> response = getList("?brandId=" + nikeId); @@ -111,10 +106,10 @@ class 상품_목록_조회 { @Test void sort_RECENT이면_최신_등록순으로_정렬한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); - registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + fixture.registerProduct(brandId, "운동화C", new BigDecimal("30000"), 30, "설명C"); ResponseEntity>> response = getList("?sort=RECENT"); @@ -130,10 +125,10 @@ class 상품_목록_조회 { @Test void sort_PRICE_ASC이면_가격_오름차순으로_정렬한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "비싼 운동화", new BigDecimal("90000"), 10, "설명"); - registerProduct(brandId, "싼 운동화", new BigDecimal("10000"), 10, "설명"); - registerProduct(brandId, "중간 운동화", new BigDecimal("50000"), 10, "설명"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "비싼 운동화", new BigDecimal("90000"), 10, "설명"); + fixture.registerProduct(brandId, "싼 운동화", new BigDecimal("10000"), 10, "설명"); + fixture.registerProduct(brandId, "중간 운동화", new BigDecimal("50000"), 10, "설명"); ResponseEntity>> response = getList("?sort=PRICE_ASC"); @@ -148,14 +143,14 @@ class 상품_목록_조회 { @Test void sort_LIKES_DESC이면_좋아요_내림차순으로_정렬한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long p1 = registerProduct(brandId, "인기 상품", new BigDecimal("10000"), 10, "설명"); - registerProduct(brandId, "보통 상품", new BigDecimal("20000"), 20, "설명"); - Long p3 = registerProduct(brandId, "최고 인기", new BigDecimal("30000"), 30, "설명"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long p1 = fixture.registerProduct(brandId, "인기 상품", new BigDecimal("10000"), 10, "설명"); + fixture.registerProduct(brandId, "보통 상품", new BigDecimal("20000"), 20, "설명"); + Long p3 = fixture.registerProduct(brandId, "최고 인기", new BigDecimal("30000"), 30, "설명"); - signUpUser("user1", "Pass1234!"); - signUpUser("user2", "Pass1234!"); - signUpUser("user3", "Pass1234!"); + fixture.signUp("user1", "Pass1234!", "홍길동", "user1@example.com"); + fixture.signUp("user2", "Pass1234!", "홍길동", "user2@example.com"); + fixture.signUp("user3", "Pass1234!", "홍길동", "user3@example.com"); likeProduct(p1, "user1", "Pass1234!"); likeProduct(p1, "user2", "Pass1234!"); @@ -176,9 +171,9 @@ class 상품_목록_조회 { @Test void sort를_지정하지_않으면_기본값_RECENT로_정렬한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); - registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + fixture.registerProduct(brandId, "운동화A", new BigDecimal("10000"), 10, "설명A"); + fixture.registerProduct(brandId, "운동화B", new BigDecimal("20000"), 20, "설명B"); ResponseEntity>> response = getList(""); @@ -230,8 +225,8 @@ class 상품_상세_조회 { @Test void 활성_상품을_조회하면_200_응답과_상품_상세_정보를_반환한다() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); ResponseEntity> response = getDetail(productId); @@ -252,9 +247,9 @@ class 상품_상세_조회 { @Test void 삭제된_상품을_조회하면_404_응답() { - Long brandId = registerBrand("나이키", "스포츠 브랜드"); - Long productId = registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - deleteProduct(productId); + Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); + Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + fixture.deleteProduct(productId); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.GET, @@ -285,51 +280,8 @@ class 상품_상세_조회 { // --- 헬퍼 메서드 --- - private Long registerBrand(String name, String description) { - BrandRequest.Register request = new BrandRequest.Register(name, description); - ResponseEntity> response = testRestTemplate.exchange( - ADMIN_BRAND_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - ProductRequest.Register request = new ProductRequest.Register( - brandId, name, price, stockQuantity, description - ); - ResponseEntity> response = testRestTemplate.exchange( - ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, - new HttpEntity<>(request, adminHeaders()), - new ParameterizedTypeReference<>() {} - ); - return response.getBody().data().id(); - } - - private void deleteProduct(Long productId) { - testRestTemplate.exchange( - ADMIN_PRODUCT_ENDPOINT + "/" + productId, HttpMethod.DELETE, - new HttpEntity<>(adminHeaders()), - new ParameterizedTypeReference>() {} - ); - } - - private void signUpUser(String loginId, String password) { - UserRequest.SignUp request = new UserRequest.SignUp( - loginId, password, "홍길동", - LocalDate.of(2000, 1, 15), loginId + "@example.com" - ); - testRestTemplate.exchange( - "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), - new ParameterizedTypeReference>() {} - ); - } - private void likeProduct(Long productId, String loginId, String password) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", loginId); - headers.set("X-Loopers-LoginPw", password); + HttpHeaders headers = fixture.userHeaders(loginId, password); testRestTemplate.exchange( "/api/v1/products/" + productId + "/likes", HttpMethod.POST, new HttpEntity<>(headers), @@ -353,10 +305,4 @@ private ResponseEntity> getDetail( ); } - private HttpHeaders adminHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-Ldap", VALID_LDAP); - return headers; - } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 58ff981ca..22edf5aba 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -2,6 +2,7 @@ import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -37,6 +38,9 @@ class UserApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private E2ETestFixture fixture; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -65,7 +69,7 @@ class 회원가입 { @Test void 이미_존재하는_로그인ID로_가입하면_409_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.SignUp duplicateRequest = new UserRequest.SignUp( "testuser", "Test5678!", "김철수", @@ -119,7 +123,7 @@ class 내_정보_조회 { @Test void 유효한_인증정보로_조회하면_마스킹된_이름과_함께_정보가_반환된다() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); ResponseEntity> response = getMyInfo("testuser", "Test1234!"); @@ -136,7 +140,7 @@ class 내_정보_조회 { void 존재하지_않는_로그인ID로_조회하면_401_응답() { ResponseEntity> response = testRestTemplate.exchange( MY_INFO_ENDPOINT, HttpMethod.GET, - new HttpEntity<>(authHeaders("notexist", "Test1234!")), + new HttpEntity<>(fixture.userHeaders("notexist", "Test1234!")), new ParameterizedTypeReference<>() {} ); @@ -148,11 +152,11 @@ class 내_정보_조회 { @Test void 비밀번호가_일치하지_않으면_401_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); ResponseEntity> response = testRestTemplate.exchange( MY_INFO_ENDPOINT, HttpMethod.GET, - new HttpEntity<>(authHeaders("testuser", "WrongPass1!")), + new HttpEntity<>(fixture.userHeaders("testuser", "WrongPass1!")), new ParameterizedTypeReference<>() {} ); @@ -182,13 +186,13 @@ class 비밀번호_변경 { @Test void 유효한_새_비밀번호로_변경하면_새_비밀번호로_인증할_수_있다() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.ChangePassword request = new UserRequest.ChangePassword("NewPass123!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, - new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new HttpEntity<>(request, fixture.userHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); @@ -200,13 +204,13 @@ class 비밀번호_변경 { @Test void 현재_비밀번호와_동일한_비밀번호로_변경하면_400_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.ChangePassword request = new UserRequest.ChangePassword("Test1234!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, - new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new HttpEntity<>(request, fixture.userHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); @@ -218,13 +222,13 @@ class 비밀번호_변경 { @Test void 인증_실패하면_401_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.ChangePassword request = new UserRequest.ChangePassword("NewPass123!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, - new HttpEntity<>(request, authHeaders("testuser", "WrongPass1!")), + new HttpEntity<>(request, fixture.userHeaders("testuser", "WrongPass1!")), new ParameterizedTypeReference<>() {} ); @@ -252,13 +256,13 @@ class 비밀번호_변경 { @Test void 비밀번호에_생년월일이_포함되면_400_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.ChangePassword request = new UserRequest.ChangePassword("Abcd20000115!"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, - new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new HttpEntity<>(request, fixture.userHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); @@ -267,13 +271,13 @@ class 비밀번호_변경 { @Test void 유효하지_않은_비밀번호면_400_응답() { - signUp("testuser", "Test1234!", "홍길동", LocalDate.of(2000, 1, 15), "test@example.com"); + fixture.signUp("testuser", "Test1234!", "홍길동", "test@example.com"); UserRequest.ChangePassword request = new UserRequest.ChangePassword("short"); ResponseEntity> response = testRestTemplate.exchange( CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, - new HttpEntity<>(request, authHeaders("testuser", "Test1234!")), + new HttpEntity<>(request, fixture.userHeaders("testuser", "Test1234!")), new ParameterizedTypeReference<>() {} ); @@ -283,11 +287,6 @@ class 비밀번호_변경 { // --- 헬퍼 메서드 --- - private void signUp(String loginId, String password, String name, LocalDate birthDate, String email) { - UserRequest.SignUp request = new UserRequest.SignUp(loginId, password, name, birthDate, email); - postSignUp(request); - } - private ResponseEntity> postSignUp(UserRequest.SignUp request) { return testRestTemplate.exchange( SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), @@ -298,15 +297,8 @@ private ResponseEntity> postSignUp(UserReque private ResponseEntity> getMyInfo(String loginId, String password) { return testRestTemplate.exchange( MY_INFO_ENDPOINT, HttpMethod.GET, - new HttpEntity<>(authHeaders(loginId, password)), + new HttpEntity<>(fixture.userHeaders(loginId, password)), new ParameterizedTypeReference<>() {} ); } - - private HttpHeaders authHeaders(String loginId, String password) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", loginId); - headers.set("X-Loopers-LoginPw", password); - return headers; - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java new file mode 100644 index 000000000..601d30ff8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java @@ -0,0 +1,99 @@ +package com.loopers.support; + +import com.loopers.application.brand.BrandRequest; +import com.loopers.application.product.ProductRequest; +import com.loopers.application.user.UserRequest; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.stereotype.Component; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Component +public class E2ETestFixture { + + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String USER_ENDPOINT = "/api/v1/users"; + + @Autowired + private TestRestTemplate restTemplate; + + // Auth + + public HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "admin-ldap"); + return headers; + } + + public HttpHeaders userHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + // Setup + + public void signUp(String loginId, String password, String name, String email) { + UserRequest.SignUp request = new UserRequest.SignUp( + loginId, password, name, + LocalDate.of(2000, 1, 15), email + ); + restTemplate.exchange( + USER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + public Long registerBrand(String name, String description) { + BrandRequest.Register request = new BrandRequest.Register(name, description); + ResponseEntity> response = restTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + public Long registerProduct(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + ProductRequest.Register request = new ProductRequest.Register( + brandId, name, price, stockQuantity, description + ); + ResponseEntity> response = restTemplate.exchange( + PRODUCT_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + // Teardown + + public void deleteBrand(Long brandId) { + restTemplate.exchange( + BRAND_ENDPOINT + "/" + brandId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + public void deleteProduct(Long productId) { + restTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId, HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } +} From 3901caab60c3ef7ce88371adcf8615f170ae9bbc Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 06:12:25 +0900 Subject: [PATCH 55/68] =?UTF-8?q?docs:=20API=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=B3=84=20HTTP=20=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=8B=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/brand.http | 37 +++++++++++++++++++++++++++ http/commerce-api/example-v1.http | 2 -- http/commerce-api/like.http | 14 +++++++++++ http/commerce-api/order.http | 36 ++++++++++++++++++++++++++ http/commerce-api/product.http | 42 +++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 http/commerce-api/brand.http delete mode 100644 http/commerce-api/example-v1.http create mode 100644 http/commerce-api/like.http create mode 100644 http/commerce-api/order.http create mode 100644 http/commerce-api/product.http diff --git a/http/commerce-api/brand.http b/http/commerce-api/brand.http new file mode 100644 index 000000000..a4e7b7ba6 --- /dev/null +++ b/http/commerce-api/brand.http @@ -0,0 +1,37 @@ +### [Admin] 브랜드 등록 +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: admin-ldap + +{ + "name": "나이키", + "description": "글로벌 스포츠 브랜드" +} + +### [Admin] 브랜드 수정 +PATCH {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: admin-ldap + +{ + "name": "나이키 코리아", + "description": "나이키 한국 공식 브랜드" +} + +### [Admin] 브랜드 삭제 +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: admin-ldap + +### [Admin] 브랜드 목록 조회 +GET {{commerce-api}}/api-admin/v1/brands?name=나이키&status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: admin-ldap + +### [Admin] 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: admin-ldap + +### 브랜드 목록 조회 +GET {{commerce-api}}/api/v1/brands?name=나이키&page=0&size=20 + +### 브랜드 상세 조회 +GET {{commerce-api}}/api/v1/brands/1 diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d265..000000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file diff --git a/http/commerce-api/like.http b/http/commerce-api/like.http new file mode 100644 index 000000000..53208522c --- /dev/null +++ b/http/commerce-api/like.http @@ -0,0 +1,14 @@ +### 좋아요 등록 +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 목록 조회 +GET {{commerce-api}}/api/v1/likes?page=0&size=20 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! diff --git a/http/commerce-api/order.http b/http/commerce-api/order.http new file mode 100644 index 000000000..8b00571e6 --- /dev/null +++ b/http/commerce-api/order.http @@ -0,0 +1,36 @@ +### 주문 생성 +POST {{commerce-api}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "orderItems": [ + { + "productId": 1, + "quantity": 2 + }, + { + "productId": 2, + "quantity": 1 + } + ] +} + +### 주문 목록 조회 +GET {{commerce-api}}/api/v1/orders?startDate=2026-01-01&endDate=2026-12-31&page=0&size=20 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 주문 상세 조회 +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### [Admin] 주문 목록 조회 +GET {{commerce-api}}/api-admin/v1/orders?page=0&size=20 +X-Loopers-Ldap: admin-ldap + +### [Admin] 주문 상세 조회 +GET {{commerce-api}}/api-admin/v1/orders/1 +X-Loopers-Ldap: admin-ldap diff --git a/http/commerce-api/product.http b/http/commerce-api/product.http new file mode 100644 index 000000000..ee4540cc6 --- /dev/null +++ b/http/commerce-api/product.http @@ -0,0 +1,42 @@ +### [Admin] 상품 등록 +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: admin-ldap + +{ + "brandId": 1, + "name": "에어맥스 90", + "price": 179000, + "stockQuantity": 100, + "description": "클래식 러닝화" +} + +### [Admin] 상품 수정 +PATCH {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: admin-ldap + +{ + "name": "에어맥스 90 리뉴얼", + "price": 189000, + "stockQuantity": 150, + "description": "2026 리뉴얼 에디션" +} + +### [Admin] 상품 삭제 +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: admin-ldap + +### [Admin] 상품 목록 조회 +GET {{commerce-api}}/api-admin/v1/products?name=에어맥스&brandId=1&status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: admin-ldap + +### [Admin] 상품 상세 조회 +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: admin-ldap + +### 상품 목록 조회 +GET {{commerce-api}}/api/v1/products?brandId=1&sort=RECENT&page=0&size=20 + +### 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 From f951be3b6a1c143d44bfe676a0edd459ee8d87c5 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 06:20:28 +0900 Subject: [PATCH 56/68] =?UTF-8?q?refactor:=20E2ETestFixture=EB=A5=BC=20@Co?= =?UTF-8?q?mponent=EC=97=90=EC=84=9C=20@Import=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java | 2 ++ .../java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java | 2 ++ .../java/com/loopers/interfaces/api/like/LikeApiE2ETest.java | 2 ++ .../com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java | 2 ++ .../java/com/loopers/interfaces/api/order/OrderApiE2ETest.java | 2 ++ .../loopers/interfaces/api/product/ProductAdminApiE2ETest.java | 2 ++ .../loopers/interfaces/api/product/ProductUserApiE2ETest.java | 2 ++ .../java/com/loopers/interfaces/api/user/UserApiE2ETest.java | 2 ++ .../src/test/java/com/loopers/support/E2ETestFixture.java | 2 -- 9 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 7c784517b..045f34c83 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -27,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class BrandAdminApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java index 4a370e30f..5ba5642fa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -23,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class BrandApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 2a1818abd..181835fc1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -26,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class LikeApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java index f55c465db..2ffa05e39 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -27,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class OrderAdminApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index dde025db1..057ad185e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -29,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class OrderApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index e22bbd54f..1a08bf2fb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -26,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ProductAdminApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java index b65edbb1a..fe0ff2fd5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductUserApiE2ETest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -25,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ProductUserApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 22edf5aba..d18a00fda 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -25,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class UserApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java index 601d30ff8..330b7b856 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java @@ -9,7 +9,6 @@ import com.loopers.interfaces.api.user.UserV1Dto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.stereotype.Component; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -19,7 +18,6 @@ import java.math.BigDecimal; import java.time.LocalDate; -@Component public class E2ETestFixture { private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; From 7ee9de737dcf8bc4053754a0c5915d2bff213879 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 27 Feb 2026 06:21:00 +0900 Subject: [PATCH 57/68] =?UTF-8?q?docs:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/like/003-like-list.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/specs/like/003-like-list.md b/docs/specs/like/003-like-list.md index a236b5bba..2766347bd 100644 --- a/docs/specs/like/003-like-list.md +++ b/docs/specs/like/003-like-list.md @@ -24,11 +24,8 @@ User | content[].brandName | String | 소속 브랜드명 | | content[].name | String | 상품명 | | content[].price | BigDecimal | 가격 | -| content[].stockQuantity | Integer | 재고 수량 | -| content[].description | String | 상품 설명 | | content[].likeCount | Integer | 좋아요 수 | | content[].createdAt | LocalDateTime | 등록일시 | -| content[].updatedAt | LocalDateTime | 수정일시 | | page | Integer | 현재 페이지 번호 | | size | Integer | 페이지 크기 | | totalElements | Long | 전체 데이터 수 | From edfcb83592a8bcf8b3afb5ade46c01b03172cd38 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 10:22:18 +0900 Subject: [PATCH 58/68] =?UTF-8?q?refactor:=20Facade=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=84=A0=EC=96=B8=EC=9D=84=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=A0=88=EB=B2=A8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=A0=88=EB=B2=A8=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/project/architecture.md | 8 ++++---- .../java/com/loopers/application/brand/BrandFacade.java | 5 ++++- .../java/com/loopers/application/like/LikeFacade.java | 2 +- .../java/com/loopers/application/order/OrderFacade.java | 5 ++++- .../com/loopers/application/product/ProductFacade.java | 5 ++++- .../java/com/loopers/application/user/UserFacade.java | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index c1b96d4e0..9cd45b415 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -43,7 +43,7 @@ interfaces → application → domain ← infrastructure ### Facade (application) - 여러 도메인의 ApplicationService 호출 오케스트레이션 -- 트랜잭션 경계 (`@Transactional`) +- 트랜잭션 경계 (메서드 레벨 선언) - 클래스 레벨 `@Validated`로 Request 검증 경계 역할 (상세: `conventions/validation.md`) - Domain Entity → Info DTO 변환 - 다른 도메인의 **ApplicationService만** 호출 (Repository 직접 호출 금지) @@ -101,7 +101,7 @@ interfaces → application → domain ← infrastructure ### 트랜잭션 전략 - **ApplicationService**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 -- **Facade**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - - 명령 메서드는 메서드 레벨 `@Transactional`로 오버라이드 + - 재사용 단위로 조회 메서드가 압도적 — 명령 메서드만 `@Transactional`로 오버라이드 +- **Facade**: 클래스 레벨 `@Transactional` 선언 금지 — 모든 메서드에 개별 선언 + - 유스케이스 단위로 읽기/쓰기 비율 예측 불가 — `@Transactional` 또는 `@Transactional(readOnly = true)` 명시 - Facade가 있으면 ApplicationService의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index af104b1cd..f365409ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -12,7 +12,6 @@ @Component @Validated @RequiredArgsConstructor -@Transactional(readOnly = true) public class BrandFacade { private final BrandService brandService; @@ -42,21 +41,25 @@ public void delete(Long brandId) { // Query + @Transactional(readOnly = true) public Page getList(@Valid BrandRequest.ListAll request) { Page brands = brandService.findBrands(request.name(), request.toDeleted(), request.toPageable()); return brands.map(BrandInfo::from); } + @Transactional(readOnly = true) public BrandInfo getDetail(Long brandId) { Brand brand = brandService.getBrand(brandId); return BrandInfo.from(brand); } + @Transactional(readOnly = true) public Page getActiveList(@Valid BrandRequest.ListActive request) { Page brands = brandService.findActiveBrands(request.name(), request.toPageable()); return brands.map(BrandInfo::from); } + @Transactional(readOnly = true) public BrandInfo getActiveDetail(Long brandId) { Brand brand = brandService.getActiveBrand(brandId); return BrandInfo.from(brand); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index b66bcfa28..f94e52b79 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -21,7 +21,6 @@ @Component @Validated @RequiredArgsConstructor -@Transactional(readOnly = true) public class LikeFacade { private final LikeService likeService; @@ -52,6 +51,7 @@ public void unlike(Long userId, Long productId) { // Query + @Transactional(readOnly = true) public Page getLikedProducts(Long userId, @Valid LikeRequest.ListLiked request) { Page likes = likeService.findLikedActiveProducts(userId, request.toPageable()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 69f1f4c17..084bf2fdc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -21,7 +21,6 @@ @Component @Validated @RequiredArgsConstructor -@Transactional(readOnly = true) public class OrderFacade { private final OrderService orderService; @@ -69,11 +68,13 @@ public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { // Query + @Transactional(readOnly = true) public OrderInfo getOrderDetail(Long userId, Long orderId) { Order order = orderService.findOrderById(orderId, userId); return OrderInfo.from(order); } + @Transactional(readOnly = true) public Page getOrderList(Long userId, @Valid OrderRequest.ListByUser request) { if (request.startDate() != null && request.endDate() != null && request.startDate().isAfter(request.endDate())) { throw new CoreException(ErrorType.BAD_REQUEST, "시작일은 종료일 이전이어야 합니다"); @@ -82,11 +83,13 @@ public Page getOrderList(Long userId, @Valid OrderReques return orders.map(OrderInfo.OrderSummary::from); } + @Transactional(readOnly = true) public OrderInfo getAdminOrderDetail(Long orderId) { Order order = orderService.findOrderById(orderId); return OrderInfo.from(order); } + @Transactional(readOnly = true) public Page getAdminOrderList(@Valid OrderRequest.ListAll request) { Page orders = orderService.findAllOrders(request.toPageable()); return orders.map(OrderInfo.OrderAdminSummary::from); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index b4859edec..211347b48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -19,7 +19,6 @@ @Component @Validated @RequiredArgsConstructor -@Transactional(readOnly = true) public class ProductFacade { private final ProductService productService; @@ -54,18 +53,21 @@ public void delete(Long productId) { // Query + @Transactional(readOnly = true) public ProductInfo getDetail(Long productId) { Product product = productService.getProduct(productId); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } + @Transactional(readOnly = true) public ProductInfo getActiveDetail(Long productId) { Product product = productService.getActiveProduct(productId); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } + @Transactional(readOnly = true) public Page getActiveList(@Valid ProductRequest.ListActive request) { Page products = productService.findActiveProducts(request.brandId(), request.toPageable()); @@ -85,6 +87,7 @@ public Page getActiveList(@Valid ProductRequest.ListActive request) return products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); } + @Transactional(readOnly = true) public Page getList(@Valid ProductRequest.ListAll request) { Page products = productService.findProducts( request.name(), request.brandId(), request.toDeleted(), request.toPageable()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index c500a09fb..00445a9eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -10,7 +10,6 @@ @Component @Validated @RequiredArgsConstructor -@Transactional(readOnly = true) public class UserFacade { private final UserService userService; @@ -34,6 +33,7 @@ public void changePassword(Long id, @Valid UserRequest.ChangePassword request) { // Query + @Transactional(readOnly = true) public UserInfo getMyInfo(Long id) { User user = userService.getById(id); return UserInfo.from(user); From d2fc4879177c9d6a664875bfc728ee488ec1e1fe Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 10:38:31 +0900 Subject: [PATCH 59/68] =?UTF-8?q?refactor:=20Entity=EC=9D=98=20update()?= =?UTF-8?q?=EB=A5=BC=20updateInfo()=EB=A1=9C=20=EB=A6=AC=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=ED=95=98=EC=97=AC=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=98=EB=8F=84=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandCommand.java | 6 ++-- .../application/brand/BrandFacade.java | 6 ++-- .../application/brand/BrandRequest.java | 2 +- .../application/brand/BrandService.java | 4 +-- .../application/product/ProductCommand.java | 6 ++-- .../application/product/ProductFacade.java | 6 ++-- .../application/product/ProductRequest.java | 2 +- .../application/product/ProductService.java | 4 +-- .../java/com/loopers/domain/brand/Brand.java | 2 +- .../com/loopers/domain/product/Product.java | 2 +- .../api/brand/BrandAdminApiV1Spec.java | 4 +-- .../api/brand/BrandAdminV1Controller.java | 6 ++-- .../api/product/ProductAdminApiV1Spec.java | 4 +-- .../api/product/ProductAdminV1Controller.java | 6 ++-- .../brand/BrandServiceIntegrationTest.java | 12 ++++---- .../order/OrderServiceIntegrationTest.java | 2 +- .../ProductServiceIntegrationTest.java | 6 ++-- .../com/loopers/domain/brand/BrandTest.java | 20 ++++++------- .../loopers/domain/product/ProductTest.java | 28 +++++++++---------- .../api/brand/BrandAdminApiE2ETest.java | 24 ++++++++-------- .../api/product/ProductAdminApiE2ETest.java | 18 ++++++------ 21 files changed, 85 insertions(+), 85 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java index 37aba0733..8da13724f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java @@ -8,9 +8,9 @@ public static Create of(String name, String description) { } } - public record Update(String name, String description) { - public static Update of(String name, String description) { - return new Update(name, description); + public record UpdateInfo(String name, String description) { + public static UpdateInfo of(String name, String description) { + return new UpdateInfo(name, description); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index f365409ef..604a8792f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -27,9 +27,9 @@ public BrandInfo register(@Valid BrandRequest.Register request) { } @Transactional - public BrandInfo update(Long brandId, @Valid BrandRequest.Update request) { - BrandCommand.Update command = BrandCommand.Update.of(request.name(), request.description()); - Brand brand = brandService.update(brandId, command); + public BrandInfo updateInfo(Long brandId, @Valid BrandRequest.UpdateInfo request) { + BrandCommand.UpdateInfo command = BrandCommand.UpdateInfo.of(request.name(), request.description()); + Brand brand = brandService.updateInfo(brandId, command); return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java index f53e12725..c123e00a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java @@ -22,7 +22,7 @@ public record Register( ) { } - public record Update( + public record UpdateInfo( @Size(min = 1, max = 100, message = "브랜드명은 1~100자여야 합니다") String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 5fd921c0f..cc24d6f28 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -36,7 +36,7 @@ public Brand register(BrandCommand.Create command) { } @Transactional - public Brand update(Long brandId, BrandCommand.Update command) { + public Brand updateInfo(Long brandId, BrandCommand.UpdateInfo command) { Brand brand = brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); @@ -44,7 +44,7 @@ public Brand update(Long brandId, BrandCommand.Update command) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } - brand.update(command.name(), command.description()); + brand.updateInfo(command.name(), command.description()); return brand; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java index f29b78416..aaea69d74 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java @@ -10,9 +10,9 @@ public static Create of(Long brandId, String name, BigDecimal price, Integer sto } } - public record Update(String name, BigDecimal price, Integer stockQuantity, String description) { - public static Update of(String name, BigDecimal price, Integer stockQuantity, String description) { - return new Update(name, price, stockQuantity, description); + public record UpdateInfo(String name, BigDecimal price, Integer stockQuantity, String description) { + public static UpdateInfo of(String name, BigDecimal price, Integer stockQuantity, String description) { + return new UpdateInfo(name, price, stockQuantity, description); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 211347b48..fdb2c327a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -37,11 +37,11 @@ public ProductInfo register(@Valid ProductRequest.Register request) { } @Transactional - public ProductInfo update(Long productId, @Valid ProductRequest.Update request) { - ProductCommand.Update command = ProductCommand.Update.of( + public ProductInfo updateInfo(Long productId, @Valid ProductRequest.UpdateInfo request) { + ProductCommand.UpdateInfo command = ProductCommand.UpdateInfo.of( request.name(), request.price(), request.stockQuantity(), request.description()); - Product product = productService.update(productId, command); + Product product = productService.updateInfo(productId, command); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java index 8484af1b2..f862a4b4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java @@ -41,7 +41,7 @@ public record Register( ) { } - public record Update( + public record UpdateInfo( @Size(min = 1, max = 200, message = "상품명은 1~200자여야 합니다") String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 436a65286..efd9af879 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -34,11 +34,11 @@ public Product register(ProductCommand.Create command) { } @Transactional - public Product update(Long productId, ProductCommand.Update command) { + public Product updateInfo(Long productId, ProductCommand.UpdateInfo command) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); - product.update(command.name(), command.price(), command.stockQuantity(), command.description()); + product.updateInfo(command.name(), command.price(), command.stockQuantity(), command.description()); return product; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 795ae8b1c..e64ddd344 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -35,7 +35,7 @@ public static Brand create(String name, String description) { return new Brand(name, description); } - public void update(String name, String description) { + public void updateInfo(String name, String description) { validateNotDeleted(); if (name != null) { validateName(name); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 47297e808..a86ef84d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -79,7 +79,7 @@ public void deductStock(int quantity) { this.stockQuantity -= quantity; } - public void update(String name, BigDecimal price, Integer stockQuantity, String description) { + public void updateInfo(String name, BigDecimal price, Integer stockQuantity, String description) { validateNotDeleted(); if (name != null) { validateName(name); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index bd6cacd12..a72b1f914 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -23,9 +23,9 @@ ApiResponse register( summary = "브랜드 수정", description = "브랜드 정보를 수정합니다." ) - ApiResponse update( + ApiResponse updateInfo( Long brandId, - BrandRequest.Update request + BrandRequest.UpdateInfo request ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index e4111586e..723e262f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -35,10 +35,10 @@ public ApiResponse register( @PatchMapping("/{brandId}") @Override - public ApiResponse update( + public ApiResponse updateInfo( @PathVariable Long brandId, - @RequestBody BrandRequest.Update request) { - BrandInfo info = brandFacade.update(brandId, request); + @RequestBody BrandRequest.UpdateInfo request) { + BrandInfo info = brandFacade.updateInfo(brandId, request); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index e37829095..b75cbdf97 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -23,9 +23,9 @@ ApiResponse register( summary = "상품 정보 수정", description = "등록된 상품의 정보를 수정합니다. 소속 브랜드는 변경할 수 없습니다." ) - ApiResponse update( + ApiResponse updateInfo( Long productId, - ProductRequest.Update request + ProductRequest.UpdateInfo request ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 7dfc13af1..27a66cbb6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -35,10 +35,10 @@ public ApiResponse register( @PatchMapping("/{productId}") @Override - public ApiResponse update( + public ApiResponse updateInfo( @PathVariable Long productId, - @RequestBody ProductRequest.Update request) { - ProductInfo info = productFacade.update(productId, request); + @RequestBody ProductRequest.UpdateInfo request) { + ProductInfo info = productFacade.updateInfo(productId, request); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index 793996ab3..9579d0a56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -79,7 +79,7 @@ class 브랜드_수정 { void 유효한_정보로_수정하면_성공한다() { Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand result = brandService.update(brand.getId(), BrandCommand.Update.of("아디다스", "독일 스포츠 브랜드")); + Brand result = brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("아디다스", "독일 스포츠 브랜드")); assertThat(result.getName()).isEqualTo("아디다스"); assertThat(result.getDescription()).isEqualTo("독일 스포츠 브랜드"); @@ -87,7 +87,7 @@ class 브랜드_수정 { @Test void 미존재_브랜드면_예외() { - assertThatThrownBy(() -> brandService.update(999L, BrandCommand.Update.of("나이키", null))) + assertThatThrownBy(() -> brandService.updateInfo(999L, BrandCommand.UpdateInfo.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); @@ -98,7 +98,7 @@ class 브랜드_수정 { Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); - assertThatThrownBy(() -> brandService.update(brand.getId(), BrandCommand.Update.of("아디다스", null))) + assertThatThrownBy(() -> brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("아디다스", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); @@ -109,7 +109,7 @@ class 브랜드_수정 { brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - assertThatThrownBy(() -> brandService.update(adidas.getId(), BrandCommand.Update.of("나이키", null))) + assertThatThrownBy(() -> brandService.updateInfo(adidas.getId(), BrandCommand.UpdateInfo.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -123,7 +123,7 @@ class 브랜드_수정 { Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - assertThatThrownBy(() -> brandService.update(adidas.getId(), BrandCommand.Update.of("나이키", null))) + assertThatThrownBy(() -> brandService.updateInfo(adidas.getId(), BrandCommand.UpdateInfo.of("나이키", null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -133,7 +133,7 @@ class 브랜드_수정 { void 자기_자신_이름으로_수정하면_정상_처리된다() { Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand result = brandService.update(brand.getId(), BrandCommand.Update.of("나이키", "변경된 설명")); + Brand result = brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("나이키", "변경된 설명")); assertThat(result.getName()).isEqualTo("나이키"); assertThat(result.getDescription()).isEqualTo("변경된 설명"); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index 6fa8aa14f..2971fd02d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -67,7 +67,7 @@ class 주문_생성 { )); Order order = orderService.createOrder(command); - product.update("런닝화", new BigDecimal("70000"), null, null); + product.updateInfo("런닝화", new BigDecimal("70000"), null, null); productRepository.save(product); Product updatedProduct = productRepository.findById(product.getId()).orElseThrow(); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 686111b14..3a2047970 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -67,7 +67,7 @@ class 상품_수정 { void 유효한_정보로_수정하면_성공한다() { Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product result = productService.update(product.getId(), ProductCommand.Update.of("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); + Product result = productService.updateInfo(product.getId(), ProductCommand.UpdateInfo.of("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); assertThat(result.getName()).isEqualTo("런닝화"); assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); @@ -77,7 +77,7 @@ class 상품_수정 { @Test void 미존재_상품이면_예외() { - assertThatThrownBy(() -> productService.update(999L, ProductCommand.Update.of("런닝화", null, null, null))) + assertThatThrownBy(() -> productService.updateInfo(999L, ProductCommand.UpdateInfo.of("런닝화", null, null, null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 상품입니다"); @@ -89,7 +89,7 @@ class 상품_수정 { product.delete(); productRepository.save(product); - assertThatThrownBy(() -> productService.update(product.getId(), ProductCommand.Update.of("런닝화", null, null, null))) + assertThatThrownBy(() -> productService.updateInfo(product.getId(), ProductCommand.UpdateInfo.of("런닝화", null, null, null))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 상품입니다"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index 10667e264..6f631d9a6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -88,7 +88,7 @@ class 수정 { void name만_수정하면_name만_변경된다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); - brand.update("아디다스", null); + brand.updateInfo("아디다스", null); assertThat(brand.getName()).isEqualTo("아디다스"); assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); @@ -98,7 +98,7 @@ class 수정 { void description만_수정하면_description만_변경된다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); - brand.update(null, "독일 스포츠 브랜드"); + brand.updateInfo(null, "독일 스포츠 브랜드"); assertThat(brand.getName()).isEqualTo("나이키"); assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); @@ -108,7 +108,7 @@ class 수정 { void 둘_다_수정하면_둘_다_변경된다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); - brand.update("아디다스", "독일 스포츠 브랜드"); + brand.updateInfo("아디다스", "독일 스포츠 브랜드"); assertThat(brand.getName()).isEqualTo("아디다스"); assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"); @@ -118,7 +118,7 @@ class 수정 { void 둘_다_null이면_변경되지_않는다() { Brand brand = Brand.create("나이키", "스포츠 브랜드"); - brand.update(null, null); + brand.updateInfo(null, null); assertThat(brand.getName()).isEqualTo("나이키"); assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); @@ -129,7 +129,7 @@ class 수정 { void name이_빈값이면_예외(String name) { Brand brand = Brand.create("나이키", "스포츠 브랜드"); - assertThatThrownBy(() -> brand.update(name, null)) + assertThatThrownBy(() -> brand.updateInfo(name, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 필수입니다"); @@ -140,7 +140,7 @@ class 수정 { Brand brand = Brand.create("나이키", "스포츠 브랜드"); String longName = "a".repeat(101); - assertThatThrownBy(() -> brand.update(longName, null)) + assertThatThrownBy(() -> brand.updateInfo(longName, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드명은 100자 이하여야 합니다"); @@ -151,7 +151,7 @@ class 수정 { Brand brand = Brand.create("나이키", "스포츠 브랜드"); String maxName = "a".repeat(100); - brand.update(maxName, null); + brand.updateInfo(maxName, null); assertThat(brand.getName()).isEqualTo(maxName); } @@ -161,7 +161,7 @@ class 수정 { Brand brand = Brand.create("나이키", "스포츠 브랜드"); String longDescription = "a".repeat(501); - assertThatThrownBy(() -> brand.update(null, longDescription)) + assertThatThrownBy(() -> brand.updateInfo(null, longDescription)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("브랜드 설명은 500자 이하여야 합니다"); @@ -172,7 +172,7 @@ class 수정 { Brand brand = Brand.create("나이키", "스포츠 브랜드"); String maxDescription = "a".repeat(500); - brand.update(null, maxDescription); + brand.updateInfo(null, maxDescription); assertThat(brand.getDescription()).isEqualTo(maxDescription); } @@ -205,7 +205,7 @@ class 삭제 { Brand brand = Brand.create("나이키", "스포츠 브랜드"); brand.delete(); - assertThatThrownBy(() -> brand.update("아디다스", "독일 스포츠 브랜드")) + assertThatThrownBy(() -> brand.updateInfo("아디다스", "독일 스포츠 브랜드")) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 브랜드입니다"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 417158f12..ec970096d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -183,7 +183,7 @@ class 수정 { void 상품명만_수정하면_상품명만_변경된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update("런닝화", null, null, null); + product.updateInfo("런닝화", null, null, null); assertThat(product.getName()).isEqualTo("런닝화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); @@ -195,7 +195,7 @@ class 수정 { void 가격만_수정하면_가격만_변경된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update(null, new BigDecimal("60000"), null, null); + product.updateInfo(null, new BigDecimal("60000"), null, null); assertThat(product.getName()).isEqualTo("운동화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); @@ -207,7 +207,7 @@ class 수정 { void 재고수량만_수정하면_재고수량만_변경된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update(null, null, 200, null); + product.updateInfo(null, null, 200, null); assertThat(product.getName()).isEqualTo("운동화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); @@ -219,7 +219,7 @@ class 수정 { void 설명만_수정하면_설명만_변경된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update(null, null, null, "가벼운 운동화"); + product.updateInfo(null, null, null, "가벼운 운동화"); assertThat(product.getName()).isEqualTo("운동화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); @@ -231,7 +231,7 @@ class 수정 { void 모든_필드를_수정하면_모두_변경된다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); + product.updateInfo("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화"); assertThat(product.getName()).isEqualTo("런닝화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("60000")); @@ -243,7 +243,7 @@ class 수정 { void 모두_null이면_변경되지_않는다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - product.update(null, null, null, null); + product.updateInfo(null, null, null, null); assertThat(product.getName()).isEqualTo("운동화"); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal("50000")); @@ -256,7 +256,7 @@ class 수정 { void 상품명이_빈값이면_예외(String name) { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - assertThatThrownBy(() -> product.update(name, null, null, null)) + assertThatThrownBy(() -> product.updateInfo(name, null, null, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("상품명은 필수입니다"); @@ -267,7 +267,7 @@ class 수정 { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); String longName = "a".repeat(201); - assertThatThrownBy(() -> product.update(longName, null, null, null)) + assertThatThrownBy(() -> product.updateInfo(longName, null, null, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("상품명은 200자 이하여야 합니다"); @@ -277,7 +277,7 @@ class 수정 { void 가격이_음수면_예외() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - assertThatThrownBy(() -> product.update(null, new BigDecimal("-1"), null, null)) + assertThatThrownBy(() -> product.updateInfo(null, new BigDecimal("-1"), null, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("가격은 0 이상이어야 합니다"); @@ -287,7 +287,7 @@ class 수정 { void 가격이_최대값을_초과하면_예외() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - assertThatThrownBy(() -> product.update(null, new BigDecimal("1000000000"), null, null)) + assertThatThrownBy(() -> product.updateInfo(null, new BigDecimal("1000000000"), null, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("가격은 999,999,999 이하여야 합니다"); @@ -297,7 +297,7 @@ class 수정 { void 재고수량이_음수면_예외() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - assertThatThrownBy(() -> product.update(null, null, -1, null)) + assertThatThrownBy(() -> product.updateInfo(null, null, -1, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("재고 수량은 0 이상이어야 합니다"); @@ -307,7 +307,7 @@ class 수정 { void 재고수량이_최대값을_초과하면_예외() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - assertThatThrownBy(() -> product.update(null, null, 10_000_000, null)) + assertThatThrownBy(() -> product.updateInfo(null, null, 10_000_000, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("재고 수량은 9,999,999 이하여야 합니다"); @@ -318,7 +318,7 @@ class 수정 { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); String longDescription = "a".repeat(1001); - assertThatThrownBy(() -> product.update(null, null, null, longDescription)) + assertThatThrownBy(() -> product.updateInfo(null, null, null, longDescription)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) .hasMessageContaining("상품 설명은 1,000자 이하여야 합니다"); @@ -329,7 +329,7 @@ class 수정 { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); product.delete(); - assertThatThrownBy(() -> product.update("런닝화", null, null, null)) + assertThatThrownBy(() -> product.updateInfo("런닝화", null, null, null)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) .hasMessageContaining("존재하지 않는 상품입니다"); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 045f34c83..93f8782c1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -146,7 +146,7 @@ class 브랜드_수정 { @Test void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update("아디다스", "독일 스포츠 브랜드"); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("아디다스", "독일 스포츠 브랜드"); ResponseEntity> response = patchUpdate(brandId, request); @@ -160,7 +160,7 @@ class 브랜드_수정 { @Test void name만_보내면_name만_수정된다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update("아디다스", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("아디다스", null); ResponseEntity> response = patchUpdate(brandId, request); @@ -174,7 +174,7 @@ class 브랜드_수정 { @Test void description만_보내면_description만_수정된다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update(null, "변경된 설명"); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo(null, "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -189,7 +189,7 @@ class 브랜드_수정 { void 중복_브랜드명이면_409_응답() { fixture.registerBrand("나이키", "스포츠 브랜드"); Long adidasId = fixture.registerBrand("아디다스", "독일 스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update("나이키", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, @@ -202,7 +202,7 @@ class 브랜드_수정 { @Test void 미존재_브랜드면_404_응답() { - BrandRequest.Update request = new BrandRequest.Update("나이키", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/999", HttpMethod.PATCH, @@ -216,7 +216,7 @@ class 브랜드_수정 { @Test void 입력_규칙_위반_시_400_응답() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update("", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, @@ -229,7 +229,7 @@ class 브랜드_수정 { @Test void 인증_누락이면_401_응답() { - BrandRequest.Update request = new BrandRequest.Update("나이키", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("나이키", null); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/1", HttpMethod.PATCH, @@ -242,7 +242,7 @@ class 브랜드_수정 { @Test void 인증_실패이면_401_응답() { - BrandRequest.Update request = new BrandRequest.Update("나이키", null); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("나이키", null); HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-Ldap", "wrong-ldap"); @@ -264,7 +264,7 @@ class 브랜드_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + adidasId, HttpMethod.PATCH, - new HttpEntity<>(new BrandRequest.Update("나이키", null), fixture.adminHeaders()), + new HttpEntity<>(new BrandRequest.UpdateInfo("나이키", null), fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -274,7 +274,7 @@ class 브랜드_수정 { @Test void 자기_자신의_현재_이름과_동일한_이름으로_수정하면_200_응답() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); - BrandRequest.Update request = new BrandRequest.Update("나이키", "변경된 설명"); + BrandRequest.UpdateInfo request = new BrandRequest.UpdateInfo("나이키", "변경된 설명"); ResponseEntity> response = patchUpdate(brandId, request); @@ -292,7 +292,7 @@ class 브랜드_수정 { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, - new HttpEntity<>(new BrandRequest.Update("변경이름", null), fixture.adminHeaders()), + new HttpEntity<>(new BrandRequest.UpdateInfo("변경이름", null), fixture.adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -623,7 +623,7 @@ private ResponseEntity> postRegister( ); } - private ResponseEntity> patchUpdate(Long brandId, BrandRequest.Update request) { + private ResponseEntity> patchUpdate(Long brandId, BrandRequest.UpdateInfo request) { return testRestTemplate.exchange( ENDPOINT + "/" + brandId, HttpMethod.PATCH, new HttpEntity<>(request, fixture.adminHeaders()), diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index 1a08bf2fb..c8d81598b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -174,7 +174,7 @@ class 상품_수정 { void 유효한_정보로_수정하면_200_응답과_수정된_정보를_반환한다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화" ); @@ -196,7 +196,7 @@ class 상품_수정 { void 상품명만_보내면_상품명만_수정된다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -215,7 +215,7 @@ class 상품_수정 { void 소속_브랜드는_변경되지_않는다() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -230,7 +230,7 @@ class 상품_수정 { @Test void 미존재_상품이면_404_응답() { - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -252,7 +252,7 @@ class 상품_수정 { Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); fixture.deleteProduct(productId); - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -269,7 +269,7 @@ class 상품_수정 { void 요청_필드_규칙_위반_시_400_응답() { Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "", null, null, null ); @@ -284,7 +284,7 @@ class 상품_수정 { @Test void 인증_헤더가_누락되면_401_응답() { - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -302,7 +302,7 @@ class 상품_수정 { @Test void 인증에_실패하면_401_응답() { - ProductRequest.Update request = new ProductRequest.Update( + ProductRequest.UpdateInfo request = new ProductRequest.UpdateInfo( "런닝화", null, null, null ); @@ -703,7 +703,7 @@ private ResponseEntity> postRegis } private ResponseEntity> patchUpdate( - Long productId, ProductRequest.Update request) { + Long productId, ProductRequest.UpdateInfo request) { return testRestTemplate.exchange( ENDPOINT + "/" + productId, HttpMethod.PATCH, new HttpEntity<>(request, fixture.adminHeaders()), From 7790d0c26897f544df3dbb2e03e09feb5fa6b6c0 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 11:47:32 +0900 Subject: [PATCH 60/68] =?UTF-8?q?refactor:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20Ser?= =?UTF-8?q?vice=EC=97=90=EC=84=9C=20Facade=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 7 ++++-- .../application/order/OrderService.java | 15 ++----------- .../java/com/loopers/domain/order/Order.java | 4 ++++ .../order/OrderServiceIntegrationTest.java | 22 +++++-------------- .../com/loopers/domain/order/OrderTest.java | 18 +++++++++++++++ 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 084bf2fdc..a45cbce05 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -70,7 +70,10 @@ public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { @Transactional(readOnly = true) public OrderInfo getOrderDetail(Long userId, Long orderId) { - Order order = orderService.findOrderById(orderId, userId); + Order order = orderService.getOrder(orderId); + if (!order.isOwnedBy(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다"); + } return OrderInfo.from(order); } @@ -85,7 +88,7 @@ public Page getOrderList(Long userId, @Valid OrderReques @Transactional(readOnly = true) public OrderInfo getAdminOrderDetail(Long orderId) { - Order order = orderService.findOrderById(orderId); + Order order = orderService.getOrder(orderId); return OrderInfo.from(order); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 7b8df07fb..b4b7dcd75 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -39,26 +39,15 @@ public Order createOrder(OrderCommand.Create command) { // Query - public Order findOrderById(Long orderId, Long userId) { - Order order = orderRepository.findByIdWithItems(orderId) + public Order getOrder(Long orderId) { + return orderRepository.findByIdWithItems(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다")); - - if (!order.getUserId().equals(userId)) { - throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다"); - } - - return order; } public Page findOrdersByUserIdAndDateRange(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); } - public Order findOrderById(Long orderId) { - return orderRepository.findByIdWithItems(orderId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다")); - } - public Page findAllOrders(Pageable pageable) { return orderRepository.findAll(pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index a20a64e68..2d3b7284b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -68,6 +68,10 @@ protected void onCreate() { this.createdAt = ZonedDateTime.now(); } + public boolean isOwnedBy(Long userId) { + return this.userId.equals(userId); + } + private void validateMaxSize() { if (orderItems.size() >= ORDER_ITEMS_MAX_SIZE) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품은 100개 이하여야 합니다"); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index 2971fd02d..4b0296569 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -83,12 +83,12 @@ class 주문_생성 { class 주문_상세_조회 { @Test - void 본인의_주문을_조회하면_주문_정보를_반환한다() { + void 주문_ID로_조회하면_주문_정보를_반환한다() { Order created = orderService.createOrder(OrderCommand.Create.of(1L, List.of( OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 2) ))); - Order order = orderService.findOrderById(created.getId(), 1L); + Order order = orderService.getOrder(created.getId()); assertThat(order.getId()).isEqualTo(created.getId()); assertThat(order.getUserId()).isEqualTo(1L); @@ -98,19 +98,7 @@ class 주문_상세_조회 { @Test void 존재하지_않는_주문이면_예외() { - assertThatThrownBy(() -> orderService.findOrderById(999L, 1L)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()) - .isEqualTo(ErrorType.NOT_FOUND)); - } - - @Test - void 본인의_주문이_아니면_예외() { - Order created = orderService.createOrder(OrderCommand.Create.of(1L, List.of( - OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) - ))); - - assertThatThrownBy(() -> orderService.findOrderById(created.getId(), 2L)) + assertThatThrownBy(() -> orderService.getOrder(999L)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()) .isEqualTo(ErrorType.NOT_FOUND)); @@ -163,7 +151,7 @@ class 주문_상세_조회_관리자 { OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 2) ))); - Order order = orderService.findOrderById(created.getId()); + Order order = orderService.getOrder(created.getId()); assertThat(order.getId()).isEqualTo(created.getId()); assertThat(order.getOrderItems()).hasSize(1); @@ -172,7 +160,7 @@ class 주문_상세_조회_관리자 { @Test void 존재하지_않는_주문이면_예외() { - assertThatThrownBy(() -> orderService.findOrderById(999L)) + assertThatThrownBy(() -> orderService.getOrder(999L)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()) .isEqualTo(ErrorType.NOT_FOUND)); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index b305ed213..f40520145 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -61,6 +61,24 @@ class 생성 { } } + @Nested + class 소유권_확인 { + + @Test + void 본인의_주문이면_true를_반환한다() { + Order order = Order.create(1L); + + assertThat(order.isOwnedBy(1L)).isTrue(); + } + + @Test + void 본인의_주문이_아니면_false를_반환한다() { + Order order = Order.create(1L); + + assertThat(order.isOwnedBy(2L)).isFalse(); + } + } + @Nested class 총_주문금액_계산 { From 2d32c59bfdb09da6af2be08e1db86a8b6ba7618e Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 12:36:07 +0900 Subject: [PATCH 61/68] =?UTF-8?q?chore:=20Entity=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=EC=8B=9D=20vs=20=EC=82=AC=EC=8B=A4=20=EC=A0=9C=EA=B3=B5=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20Facade=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/validation.md | 20 ++++++++++++++++++++ .claude/rules/project/architecture.md | 2 ++ 2 files changed, 22 insertions(+) diff --git a/.claude/rules/conventions/validation.md b/.claude/rules/conventions/validation.md index e6da91c88..5e9068ac9 100644 --- a/.claude/rules/conventions/validation.md +++ b/.claude/rules/conventions/validation.md @@ -13,6 +13,26 @@ - Entity가 검증의 최종 방어선 — Request 검증이 빠져도 Entity에서 반드시 잡아야 함 - Facade에 `@Validated`를 선언하여, `@Valid` 파라미터를 Facade 진입 시점에 검증한다 +## Entity의 두 가지 표현: 불변식 강제 vs 사실 제공 + +### 판별 기준 + +> **"이 조건이 깨지면 Entity 자체가 유효하지 않은 상태가 되는가?"** +> - Yes → **불변식** → Entity가 예외를 던진다 +> - No → **사실 제공** → Entity는 boolean만 반환, 호출자(Facade)가 맥락에 맞게 판단한다 + +### 역할 비교 + +| 역할 | Entity 행동 | 판단 주체 | 예시 | +|------|-----------|----------|------| +| 불변식 강제 | 검증 + 예외 (`CoreException`) | Entity | 재고 차감 시 음수 방지, 주문 상품 중복 방지, 이름 빈 문자열 방지 | +| 사실 제공 | boolean 반환 | 호출자 (Facade) | `isOwnedBy()`, `isActive()`, `isExpired()` | + +### 접근 제어를 Facade가 판단하는 이유 + +접근 제어는 유스케이스마다 해석이 달라질 수 있다 (예: 관리자는 소유자 확인 불필요). +따라서 유스케이스를 소유하는 Facade가 판단한다. + ## 예외 처리 - 비즈니스 예외는 `CoreException`으로 통일 diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index 9cd45b415..b9d7dde1d 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -45,6 +45,7 @@ interfaces → application → domain ← infrastructure - 여러 도메인의 ApplicationService 호출 오케스트레이션 - 트랜잭션 경계 (메서드 레벨 선언) - 클래스 레벨 `@Validated`로 Request 검증 경계 역할 (상세: `conventions/validation.md`) +- **접근 제어 판단** — 유스케이스별 권한 검증 (상세: `conventions/validation.md`) - Domain Entity → Info DTO 변환 - 다른 도메인의 **ApplicationService만** 호출 (Repository 직접 호출 금지) - Controller는 **항상 Facade만 호출** (일관성 유지, Entity가 Controller에 노출되지 않음) @@ -94,6 +95,7 @@ interfaces → application → domain ← infrastructure - 자기 데이터의 검증, 상태 전이, 계산 - setter 대신 의미 있는 메서드명 (`changeToFailed()`, `deductStock()`) - 생성자에서 필수 불변 조건(invariant) 검증 +- **불변식은 예외로 방어, 불변식이 아닌 조건은 사실만 제공** — 판단은 호출자 책임 (상세: `conventions/validation.md`) ### Repository (domain → infrastructure) - 인터페이스는 `domain/` 패키지에 정의 From 558a947bfdeb64f6adb05139ab49e3b5baca525e Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 14:37:06 +0900 Subject: [PATCH 62/68] =?UTF-8?q?chore:=20Request=20DTO=20=EC=86=8C?= =?UTF-8?q?=EC=86=8D=EC=9D=84=20interfaces=EB=A1=9C=20=EC=A0=95=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EA=B2=80=EC=A6=9D=20=EC=A3=BC=EC=B2=B4?= =?UTF-8?q?=EB=A5=BC=20Controller(@Valid)=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/code-ordering.md | 2 +- .claude/rules/conventions/dto.md | 40 ++++++++++++++-------- .claude/rules/conventions/validation.md | 5 ++- .claude/rules/project/architecture.md | 12 +++---- .claude/skills/implement/SKILL.md | 4 +-- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/.claude/rules/conventions/code-ordering.md b/.claude/rules/conventions/code-ordering.md index b9c493452..3b6cf5982 100644 --- a/.claude/rules/conventions/code-ordering.md +++ b/.claude/rules/conventions/code-ordering.md @@ -18,7 +18,7 @@ | Repository (interface) | `// Command` → `// Query` | save → find, exists | | RepositoryImpl | `// Command` → `// Query` | Repository 인터페이스와 동일 순서 | | JpaRepository | `// Query` 만 | 상속 메서드 생략, 직접 정의한 메서드만 기재 | -| Request | `// Command` → `// Query` | Place, Cancel → ListByUser | +| Request (interfaces) | `// Command` → `// Query` | Place, Cancel → ListByUser | | Command | 구분 주석 없음 | 기능별 나열 | | V1Dto | `// Response` 만 | Response 전용 | diff --git a/.claude/rules/conventions/dto.md b/.claude/rules/conventions/dto.md index 7edf4216a..9c23bb6d5 100644 --- a/.claude/rules/conventions/dto.md +++ b/.claude/rules/conventions/dto.md @@ -4,8 +4,8 @@ | DTO | 소속 | 역할 | 형태 | |-----|------|------|------| -| `Request` | application | Facade 입력 (명령 + 조회) | `Place`, `Cancel`, `ListByUser` 등 | -| `Command` | application | Service 입력 (비즈니스 보강) | `Create`, `Update` 등 | +| `Request` | interfaces | Controller 입력 (명령 + 조회) | `Place`, `Cancel`, `ListByUser` 등 | +| `Command` | application | Facade/Service 입력 (비즈니스 보강) | `Create`, `Update` 등 | | `Info` | application | Facade 출력 → Controller | 도메인 데이터의 읽기 전용 표현 | | `V1Dto` | interfaces | HTTP 응답 전용 | `XxxResponse` | @@ -14,15 +14,16 @@ ## 데이터 흐름 ``` -Controller Facade Service -Request.Place → @Valid Request.Place → - Command.Create → Command.Create → Entity - Info ← Entity ← -V1Dto.Response ← Info +Controller Facade Service +@Valid Request.Place → +request.toCommand() → Command.Place → + Command.Create → Command.Create → Entity + Info ← Entity ← +V1Dto.Response ← Info ``` -1. **Controller → Facade**: `Request`를 `@RequestBody`로 직접 받아 Facade에 전달 -2. **Facade → Service**: `Request`를 DB 조회 등으로 보강하여 `Command`로 재조립하여 전달 +1. **Controller**: `Request`를 `@Valid`로 검증 후, 명령은 `request.toCommand()`로 Command 변환하여 Facade에 전달, 조회는 개별 파라미터를 추출하여 Facade에 전달 +2. **Facade → Service**: Command를 DB 조회 등으로 보강하여 Service에 전달 3. **Service → Facade**: Entity 반환 4. **Facade → Controller**: Entity를 `Info`로 변환하여 반환 5. **Controller → 클라이언트**: `Info`를 `V1Dto.Response`로 변환 @@ -30,7 +31,7 @@ V1Dto.Response ← Info ## Request 구조 Request는 도메인별로 하나의 클래스에 중첩 record로 정의한다. -Controller에서 `@RequestBody`로 직접 받으며, Facade에서 `@Validated`로 검증한다. +Controller에서 `@Valid`로 검증하고, Command Request에는 `toCommand()` 메서드를 정의한다. ```java public record OrderRequest() { @@ -38,12 +39,23 @@ public record OrderRequest() { public record Place( @NotNull @Size(min = 1, max = 100) List<@Valid PlaceItem> orderItems - ) {} + ) { + public OrderCommand.Place toCommand() { + List items = orderItems.stream() + .map(item -> OrderCommand.PlaceItem.of(item.productId(), item.quantity())) + .toList(); + return OrderCommand.Place.of(items); + } + } public record PlaceItem( @NotNull Long productId, @NotNull @Min(1) @Max(9999999) Integer quantity ) {} - public record Cancel(String cancelReason) {} + public record Cancel(String cancelReason) { + public OrderCommand.Cancel toCommand(Long userId, Long orderId) { + return OrderCommand.Cancel.of(userId, orderId, cancelReason); + } + } // Query public record ListByUser(LocalDate startDate, LocalDate endDate, Integer page, Integer size) {} @@ -58,7 +70,7 @@ public record OrderRequest() { ## Command 구조 Command는 도메인별로 하나의 클래스에 중첩 record로 정의한다. -Facade가 Request를 DB 조회 결과 등으로 보강하여 생성한다. +Controller에서 `Request.toCommand()`로 생성하거나, Facade가 DB 조회 결과 등으로 보강하여 생성한다. `of(...)` 정적 팩토리 메서드로 생성한다. ```java public record OrderCommand() { @@ -105,7 +117,7 @@ public record OrderInfo( ## V1Dto 구조 -V1Dto는 Response 전용이다. Request는 application 계층의 `Request`를 사용한다. +V1Dto는 Response 전용이다. Request는 같은 interfaces 계층의 `Request`를 사용한다. ```java public class OrderV1Dto { diff --git a/.claude/rules/conventions/validation.md b/.claude/rules/conventions/validation.md index 5e9068ac9..2cbbaca9a 100644 --- a/.claude/rules/conventions/validation.md +++ b/.claude/rules/conventions/validation.md @@ -4,14 +4,13 @@ | 위치 | 역할 | 예시 | |---|---|---| -| `@Valid` (Request) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | -| Facade (`@Validated`) | Request 검증 경계 | 클래스 레벨 `@Validated`로 `@Valid` 트리거 | +| Controller (`@Valid`) | Fail-Fast 형식 검증 | `@NotNull`, `@NotBlank`, `@Size`, `@Positive`, `@PositiveOrZero` | | Entity | 자기 데이터의 모든 비즈니스 검증 | 길이, 범위, 상태 전이 규칙, 불변 조건 | | ApplicationService | DB 조회가 필요한 검증 | 유일성, 존재 여부, 권한 | +- Controller가 `@Valid`로 Request를 검증하고, `toCommand()`로 Command 변환 후 Facade에 전달 - `@Valid`는 Entity 검증 중 일부를 앞단에서 선처리하는 것 (중복 검증 허용) - Entity가 검증의 최종 방어선 — Request 검증이 빠져도 Entity에서 반드시 잡아야 함 -- Facade에 `@Validated`를 선언하여, `@Valid` 파라미터를 Facade 진입 시점에 검증한다 ## Entity의 두 가지 표현: 불변식 강제 vs 사실 제공 diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index b9d7dde1d..50a49a1f1 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -3,8 +3,8 @@ ## 패키지 구조 (DDD) ``` com.loopers/ -├── interfaces/ # REST 컨트롤러, Response DTO (V1Dto) -├── application/ # Facade, ApplicationService(xxxService), Request, Command, Info +├── interfaces/ # REST 컨트롤러, Request DTO, Response DTO (V1Dto) +├── application/ # Facade, ApplicationService(xxxService), Command, Info ├── domain/ # Entity, Domain Service, Repository 인터페이스, VO ├── infrastructure/ # Repository 구현체, 외부 어댑터 └── support/ # 횡단 관심사 (에러, 유틸, 글로벌 핸들러) @@ -36,15 +36,15 @@ interfaces → application → domain ← infrastructure ## 계층별 역할 ### Controller (interfaces) -- HTTP 요청/응답 변환만 담당 -- `Request`를 `@RequestBody`로 직접 받아 Facade에 전달 (상세: `conventions/dto.md`) +- HTTP 요청/응답 변환 + 입력 검증 + Facade 호출 - API별 enum은 Response DTO 내부에 inner enum으로 정의 -- 검증 및 예외 처리 규칙은 `conventions/validation.md` 참고 +- 입출력 데이터 흐름은 `conventions/dto.md` 참고 +- 검증 규칙은 `conventions/validation.md` 참고 ### Facade (application) - 여러 도메인의 ApplicationService 호출 오케스트레이션 - 트랜잭션 경계 (메서드 레벨 선언) -- 클래스 레벨 `@Validated`로 Request 검증 경계 역할 (상세: `conventions/validation.md`) +- 명령 입력: `Command` (Controller에서 변환), 조회 입력: 개별 파라미터 - **접근 제어 판단** — 유스케이스별 권한 검증 (상세: `conventions/validation.md`) - Domain Entity → Info DTO 변환 - 다른 도메인의 **ApplicationService만** 호출 (Repository 직접 호출 금지) diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index 41d182cee..033b20036 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -114,8 +114,8 @@ REPORT → AC 매핑 표 + 파일 목록 보고 ``` 1. domain → Entity, Repository 인터페이스 2. infra → RepositoryImpl, JpaRepository -3. application → Service, Facade, Info DTO -4. interfaces → Controller, ApiSpec, Request/Response DTO +3. application → Service, Facade, Command, Info DTO +4. interfaces → Controller, ApiSpec, Request DTO, Response DTO (V1Dto) ``` ### 점진적 실행 From 17847d526abb3f6464cdb66241fe2516068806ea Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 15:00:54 +0900 Subject: [PATCH 63/68] =?UTF-8?q?refactor:=20Request=20DTO=EB=A5=BC=20inte?= =?UTF-8?q?rfaces=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EA=B3=A0=20Comman?= =?UTF-8?q?d=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20Request=EC=99=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandCommand.java | 6 +- .../application/brand/BrandFacade.java | 18 ++-- .../application/brand/BrandService.java | 2 +- .../loopers/application/like/LikeFacade.java | 8 +- .../application/order/OrderCommand.java | 12 +++ .../application/order/OrderFacade.java | 31 +++--- .../application/product/ProductCommand.java | 6 +- .../application/product/ProductFacade.java | 25 ++--- .../application/product/ProductService.java | 2 +- .../loopers/application/user/UserFacade.java | 11 +-- .../interfaces/api/ApiControllerAdvice.java | 10 -- .../api/brand/BrandAdminApiV1Spec.java | 1 - .../api/brand/BrandAdminV1Controller.java | 15 +-- .../interfaces/api/brand/BrandApiV1Spec.java | 1 - .../api}/brand/BrandRequest.java | 9 +- .../api/brand/BrandV1Controller.java | 7 +- .../interfaces/api/like/LikeApiV1Spec.java | 1 - .../api}/like/LikeRequest.java | 2 +- .../interfaces/api/like/LikeV1Controller.java | 7 +- .../api/order/OrderAdminApiV1Spec.java | 1 - .../api/order/OrderAdminV1Controller.java | 6 +- .../interfaces/api/order/OrderApiV1Spec.java | 1 - .../api}/order/OrderRequest.java | 9 +- .../api/order/OrderV1Controller.java | 11 ++- .../api/product/ProductAdminApiV1Spec.java | 1 - .../api/product/ProductAdminV1Controller.java | 15 +-- .../api}/product/ProductRequest.java | 9 +- .../api/product/ProductUserApiV1Spec.java | 1 - .../api/product/ProductUserV1Controller.java | 7 +- .../interfaces/api/user/UserApiV1Spec.java | 1 - .../api}/user/UserRequest.java | 9 +- .../interfaces/api/user/UserV1Controller.java | 11 ++- .../brand/BrandServiceIntegrationTest.java | 84 ++++++++-------- .../ProductServiceIntegrationTest.java | 96 +++++++++---------- .../api/brand/BrandAdminApiE2ETest.java | 1 - .../api/order/OrderAdminApiE2ETest.java | 1 - .../interfaces/api/order/OrderApiE2ETest.java | 1 - .../api/product/ProductAdminApiE2ETest.java | 1 - .../interfaces/api/user/UserApiE2ETest.java | 1 - .../com/loopers/support/E2ETestFixture.java | 6 +- 40 files changed, 227 insertions(+), 220 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{application => interfaces/api}/brand/BrandRequest.java (88%) rename apps/commerce-api/src/main/java/com/loopers/{application => interfaces/api}/like/LikeRequest.java (93%) rename apps/commerce-api/src/main/java/com/loopers/{application => interfaces/api}/order/OrderRequest.java (87%) rename apps/commerce-api/src/main/java/com/loopers/{application => interfaces/api}/product/ProductRequest.java (91%) rename apps/commerce-api/src/main/java/com/loopers/{application => interfaces/api}/user/UserRequest.java (73%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java index 8da13724f..6e7971a37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java @@ -2,9 +2,9 @@ public record BrandCommand() { - public record Create(String name, String description) { - public static Create of(String name, String description) { - return new Create(name, description); + public record Register(String name, String description) { + public static Register of(String name, String description) { + return new Register(name, description); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 604a8792f..fdacdfb71 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -2,15 +2,13 @@ import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; @Component -@Validated @RequiredArgsConstructor public class BrandFacade { @@ -20,15 +18,13 @@ public class BrandFacade { // Command @Transactional - public BrandInfo register(@Valid BrandRequest.Register request) { - BrandCommand.Create command = BrandCommand.Create.of(request.name(), request.description()); + public BrandInfo register(BrandCommand.Register command) { Brand brand = brandService.register(command); return BrandInfo.from(brand); } @Transactional - public BrandInfo updateInfo(Long brandId, @Valid BrandRequest.UpdateInfo request) { - BrandCommand.UpdateInfo command = BrandCommand.UpdateInfo.of(request.name(), request.description()); + public BrandInfo updateInfo(Long brandId, BrandCommand.UpdateInfo command) { Brand brand = brandService.updateInfo(brandId, command); return BrandInfo.from(brand); } @@ -42,8 +38,8 @@ public void delete(Long brandId) { // Query @Transactional(readOnly = true) - public Page getList(@Valid BrandRequest.ListAll request) { - Page brands = brandService.findBrands(request.name(), request.toDeleted(), request.toPageable()); + public Page getList(String name, Boolean deleted, Pageable pageable) { + Page brands = brandService.findBrands(name, deleted, pageable); return brands.map(BrandInfo::from); } @@ -54,8 +50,8 @@ public BrandInfo getDetail(Long brandId) { } @Transactional(readOnly = true) - public Page getActiveList(@Valid BrandRequest.ListActive request) { - Page brands = brandService.findActiveBrands(request.name(), request.toPageable()); + public Page getActiveList(String name, Pageable pageable) { + Page brands = brandService.findActiveBrands(name, pageable); return brands.map(BrandInfo::from); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index cc24d6f28..4da2d9786 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -26,7 +26,7 @@ public class BrandService { // Command @Transactional - public Brand register(BrandCommand.Create command) { + public Brand register(BrandCommand.Register command) { if (brandRepository.existsByName(command.name())) { throw new CoreException(ErrorType.CONFLICT, "이미 등록된 브랜드입니다"); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index f94e52b79..f5959924d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -7,19 +7,17 @@ import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Component -@Validated @RequiredArgsConstructor public class LikeFacade { @@ -52,8 +50,8 @@ public void unlike(Long userId, Long productId) { // Query @Transactional(readOnly = true) - public Page getLikedProducts(Long userId, @Valid LikeRequest.ListLiked request) { - Page likes = likeService.findLikedActiveProducts(userId, request.toPageable()); + public Page getLikedProducts(Long userId, Pageable pageable) { + Page likes = likeService.findLikedActiveProducts(userId, pageable); Set productIds = likes.getContent().stream() .map(Like::getProductId) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java index b8da92b8c..a76a26afd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java @@ -5,6 +5,18 @@ public record OrderCommand() { + public record Place(List items) { + public static Place of(List items) { + return new Place(items); + } + } + + public record PlaceItem(Long productId, Integer quantity) { + public static PlaceItem of(Long productId, Integer quantity) { + return new PlaceItem(productId, quantity); + } + } + public record Create( Long userId, List items diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index a45cbce05..4154dd319 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -5,13 +5,15 @@ import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -19,7 +21,6 @@ import java.util.stream.Collectors; @Component -@Validated @RequiredArgsConstructor public class OrderFacade { @@ -29,11 +30,11 @@ public class OrderFacade { // Command @Transactional - public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { - var items = request.orderItems(); + public OrderInfo createOrder(Long userId, OrderCommand.Place command) { + var items = command.items(); Set productIds = items.stream() - .map(OrderRequest.PlaceItem::productId) + .map(OrderCommand.PlaceItem::productId) .collect(Collectors.toSet()); if (productIds.size() != items.size()) { @@ -42,8 +43,8 @@ public OrderInfo createOrder(Long userId, @Valid OrderRequest.Place request) { Map productQuantities = items.stream() .collect(Collectors.toMap( - OrderRequest.PlaceItem::productId, - OrderRequest.PlaceItem::quantity + OrderCommand.PlaceItem::productId, + OrderCommand.PlaceItem::quantity )); List products = productService.deductStocks(productQuantities); @@ -78,11 +79,15 @@ public OrderInfo getOrderDetail(Long userId, Long orderId) { } @Transactional(readOnly = true) - public Page getOrderList(Long userId, @Valid OrderRequest.ListByUser request) { - if (request.startDate() != null && request.endDate() != null && request.startDate().isAfter(request.endDate())) { + public Page getOrderList(Long userId, LocalDate startDate, LocalDate endDate, Pageable pageable) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new CoreException(ErrorType.BAD_REQUEST, "시작일은 종료일 이전이어야 합니다"); } - Page orders = orderService.findOrdersByUserIdAndDateRange(userId, request.startDateTime(), request.endDateTime(), request.toPageable()); + ZonedDateTime startDateTime = startDate != null + ? startDate.atStartOfDay(ZoneId.systemDefault()) : null; + ZonedDateTime endDateTime = endDate != null + ? endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()) : null; + Page orders = orderService.findOrdersByUserIdAndDateRange(userId, startDateTime, endDateTime, pageable); return orders.map(OrderInfo.OrderSummary::from); } @@ -93,8 +98,8 @@ public OrderInfo getAdminOrderDetail(Long orderId) { } @Transactional(readOnly = true) - public Page getAdminOrderList(@Valid OrderRequest.ListAll request) { - Page orders = orderService.findAllOrders(request.toPageable()); + public Page getAdminOrderList(Pageable pageable) { + Page orders = orderService.findAllOrders(pageable); return orders.map(OrderInfo.OrderAdminSummary::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java index aaea69d74..cf31e12a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java @@ -4,9 +4,9 @@ public record ProductCommand() { - public record Create(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - public static Create of(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { - return new Create(brandId, name, price, stockQuantity, description); + public record Register(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + public static Register of(Long brandId, String name, BigDecimal price, Integer stockQuantity, String description) { + return new Register(brandId, name, price, stockQuantity, description); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index fdb2c327a..c11de7b49 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -5,19 +5,17 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.domain.product.Product; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Component -@Validated @RequiredArgsConstructor public class ProductFacade { @@ -27,20 +25,14 @@ public class ProductFacade { // Command @Transactional - public ProductInfo register(@Valid ProductRequest.Register request) { - Brand brand = brandService.getActiveBrand(request.brandId()); - ProductCommand.Create command = ProductCommand.Create.of( - request.brandId(), request.name(), request.price(), - request.stockQuantity(), request.description()); + public ProductInfo register(ProductCommand.Register command) { + Brand brand = brandService.getActiveBrand(command.brandId()); Product product = productService.register(command); return ProductInfo.from(product, brand.getName()); } @Transactional - public ProductInfo updateInfo(Long productId, @Valid ProductRequest.UpdateInfo request) { - ProductCommand.UpdateInfo command = ProductCommand.UpdateInfo.of( - request.name(), request.price(), - request.stockQuantity(), request.description()); + public ProductInfo updateInfo(Long productId, ProductCommand.UpdateInfo command) { Product product = productService.updateInfo(productId, command); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand.getName()); @@ -68,8 +60,8 @@ public ProductInfo getActiveDetail(Long productId) { } @Transactional(readOnly = true) - public Page getActiveList(@Valid ProductRequest.ListActive request) { - Page products = productService.findActiveProducts(request.brandId(), request.toPageable()); + public Page getActiveList(Long brandId, Pageable pageable) { + Page products = productService.findActiveProducts(brandId, pageable); Set brandIds = products.getContent().stream() .map(Product::getBrandId) @@ -88,9 +80,8 @@ public Page getActiveList(@Valid ProductRequest.ListActive request) } @Transactional(readOnly = true) - public Page getList(@Valid ProductRequest.ListAll request) { - Page products = productService.findProducts( - request.name(), request.brandId(), request.toDeleted(), request.toPageable()); + public Page getList(String name, Long brandId, Boolean deleted, Pageable pageable) { + Page products = productService.findProducts(name, brandId, deleted, pageable); Set brandIds = products.getContent().stream() .map(Product::getBrandId) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index efd9af879..6fe87df4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -27,7 +27,7 @@ public class ProductService { // Command @Transactional - public Product register(ProductCommand.Create command) { + public Product register(ProductCommand.Register command) { Product product = Product.create(command.brandId(), command.name(), command.price(), command.stockQuantity(), command.description()); return productRepository.save(product); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 00445a9eb..241a61fee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,14 +1,11 @@ package com.loopers.application.user; import com.loopers.domain.user.User; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; @Component -@Validated @RequiredArgsConstructor public class UserFacade { @@ -17,17 +14,13 @@ public class UserFacade { // Command @Transactional - public UserInfo signUp(@Valid UserRequest.SignUp request) { - UserCommand.SignUp command = UserCommand.SignUp.of( - request.loginId(), request.password(), request.name(), - request.birthDate(), request.email()); + public UserInfo signUp(UserCommand.SignUp command) { User user = userService.signUp(command); return UserInfo.from(user); } @Transactional - public void changePassword(Long id, @Valid UserRequest.ChangePassword request) { - UserCommand.ChangePassword command = UserCommand.ChangePassword.of(id, request.newPassword()); + public void changePassword(UserCommand.ChangePassword command) { userService.changePassword(command); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index ff96d8ad4..cce96ce26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -40,15 +39,6 @@ public ResponseEntity> handleBadRequest(MethodArgumentNotValidExc return failureResponse(ErrorType.BAD_REQUEST, message); } - @ExceptionHandler - public ResponseEntity> handleBadRequest(ConstraintViolationException e) { - String message = e.getConstraintViolations().stream() - .map(violation -> violation.getMessage()) - .findFirst() - .orElse("잘못된 요청입니다."); - return failureResponse(ErrorType.BAD_REQUEST, message); - } - @ExceptionHandler public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { String name = e.getName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java index a72b1f914..9666ef3fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 723e262f3..cc97a100e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -28,8 +28,8 @@ public class BrandAdminV1Controller implements BrandAdminApiV1Spec { @PostMapping @Override public ApiResponse register( - @RequestBody BrandRequest.Register request) { - BrandInfo info = brandFacade.register(request); + @RequestBody @Valid BrandRequest.Register request) { + BrandInfo info = brandFacade.register(request.toCommand()); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @@ -37,8 +37,8 @@ public ApiResponse register( @Override public ApiResponse updateInfo( @PathVariable Long brandId, - @RequestBody BrandRequest.UpdateInfo request) { - BrandInfo info = brandFacade.updateInfo(brandId, request); + @RequestBody @Valid BrandRequest.UpdateInfo request) { + BrandInfo info = brandFacade.updateInfo(brandId, request.toCommand()); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } @@ -54,8 +54,9 @@ public ApiResponse delete(@PathVariable Long brandId) { @GetMapping @Override public ApiResponse> list( - BrandRequest.ListAll request) { - Page brands = brandFacade.getList(request); + @Valid BrandRequest.ListAll request) { + Page brands = brandFacade.getList( + request.name(), request.toDeleted(), request.toPageable()); PageResponse pageResponse = PageResponse.from(brands, BrandAdminV1Dto.BrandResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java index 9de35e3c5..f35874202 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandRequest.java similarity index 88% rename from apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandRequest.java index c123e00a5..db0ec52dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandRequest.java @@ -1,5 +1,6 @@ -package com.loopers.application.brand; +package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandCommand; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -20,6 +21,9 @@ public record Register( @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") String description ) { + public BrandCommand.Register toCommand() { + return BrandCommand.Register.of(name, description); + } } public record UpdateInfo( @@ -29,6 +33,9 @@ public record UpdateInfo( @Size(max = 500, message = "브랜드 설명은 500자 이하여야 합니다") String description ) { + public BrandCommand.UpdateInfo toCommand() { + return BrandCommand.UpdateInfo.of(name, description); + } } // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index 3dc47a0fa..b20c5ea5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.GetMapping; @@ -24,8 +24,9 @@ public class BrandV1Controller implements BrandApiV1Spec { @GetMapping @Override public ApiResponse> list( - BrandRequest.ListActive request) { - Page brands = brandFacade.getActiveList(request); + @Valid BrandRequest.ListActive request) { + Page brands = brandFacade.getActiveList( + request.name(), request.toPageable()); PageResponse pageResponse = PageResponse.from(brands, BrandV1Dto.BrandResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java index 74aaed3fb..facf978a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeRequest.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeRequest.java index de881e1e8..4f836ef3a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeRequest.java @@ -1,4 +1,4 @@ -package com.loopers.application.like; +package com.loopers.interfaces.api.like; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index e3a5dc685..6932da1c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -2,11 +2,11 @@ import com.loopers.application.like.LikeFacade; import com.loopers.application.like.LikeProductInfo; -import com.loopers.application.like.LikeRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -46,9 +46,10 @@ public ApiResponse unlike( @GetMapping("/api/v1/likes") @Override public ApiResponse> list( - LikeRequest.ListLiked request, + @Valid LikeRequest.ListLiked request, @AuthUser AuthenticatedUser authUser) { - Page likedProducts = likeFacade.getLikedProducts(authUser.id(), request); + Page likedProducts = likeFacade.getLikedProducts( + authUser.id(), request.toPageable()); return ApiResponse.success(PageResponse.from(likedProducts, LikeV1Dto.LikeProductResponse::from)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java index 7bf62cbf6..b0b3aaa6b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java index 1a17421ba..029a78591 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.GetMapping; @@ -24,8 +24,8 @@ public class OrderAdminV1Controller implements OrderAdminApiV1Spec { @GetMapping @Override public ApiResponse> list( - OrderRequest.ListAll request) { - Page orders = orderFacade.getAdminOrderList(request); + @Valid OrderRequest.ListAll request) { + Page orders = orderFacade.getAdminOrderList(request.toPageable()); PageResponse pageResponse = PageResponse.from(orders, OrderAdminV1Dto.OrderListResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java index f99f79c87..bd6cbd29f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java index b1c0538df..9f2391f4e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java @@ -1,5 +1,6 @@ -package com.loopers.application.order; +package com.loopers.interfaces.api.order; +import com.loopers.application.order.OrderCommand; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -24,6 +25,12 @@ public record Place( @Size(min = 1, max = 100, message = "주문 상품은 1~100건이어야 합니다") List<@Valid PlaceItem> orderItems ) { + public OrderCommand.Place toCommand() { + List items = orderItems.stream() + .map(item -> OrderCommand.PlaceItem.of(item.productId(), item.quantity())) + .toList(); + return OrderCommand.Place.of(items); + } } public record PlaceItem( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index c889e17f2..09d096a1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -2,11 +2,11 @@ import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.GetMapping; @@ -29,8 +29,8 @@ public class OrderV1Controller implements OrderApiV1Spec { @Override public ApiResponse createOrder( @AuthUser AuthenticatedUser user, - @RequestBody OrderRequest.Place request) { - OrderInfo info = orderFacade.createOrder(user.id(), request); + @RequestBody @Valid OrderRequest.Place request) { + OrderInfo info = orderFacade.createOrder(user.id(), request.toCommand()); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } @@ -49,8 +49,9 @@ public ApiResponse getOrderDetail( @Override public ApiResponse> listOrders( @AuthUser AuthenticatedUser user, - OrderRequest.ListByUser request) { - Page orders = orderFacade.getOrderList(user.id(), request); + @Valid OrderRequest.ListByUser request) { + Page orders = orderFacade.getOrderList( + user.id(), request.startDate(), request.endDate(), request.toPageable()); PageResponse pageResponse = PageResponse.from(orders, OrderV1Dto.OrderListResponse::from); return ApiResponse.success(pageResponse); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java index b75cbdf97..8f797eeaf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 27a66cbb6..203ef0c13 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.DeleteMapping; @@ -28,8 +28,8 @@ public class ProductAdminV1Controller implements ProductAdminApiV1Spec { @PostMapping @Override public ApiResponse register( - @RequestBody ProductRequest.Register request) { - ProductInfo info = productFacade.register(request); + @RequestBody @Valid ProductRequest.Register request) { + ProductInfo info = productFacade.register(request.toCommand()); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } @@ -37,8 +37,8 @@ public ApiResponse register( @Override public ApiResponse updateInfo( @PathVariable Long productId, - @RequestBody ProductRequest.UpdateInfo request) { - ProductInfo info = productFacade.updateInfo(productId, request); + @RequestBody @Valid ProductRequest.UpdateInfo request) { + ProductInfo info = productFacade.updateInfo(productId, request.toCommand()); return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(info)); } @@ -61,8 +61,9 @@ public ApiResponse detail(@PathVariable Long @GetMapping @Override public ApiResponse> list( - ProductRequest.ListAll request) { - Page products = productFacade.getList(request); + @Valid ProductRequest.ListAll request) { + Page products = productFacade.getList( + request.name(), request.brandId(), request.toDeleted(), request.toPageable()); PageResponse pageResponse = PageResponse.from(products, ProductAdminV1Dto.ProductResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductRequest.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductRequest.java index f862a4b4a..12a4454f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductRequest.java @@ -1,5 +1,6 @@ -package com.loopers.application.product; +package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductCommand; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; @@ -39,6 +40,9 @@ public record Register( @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") String description ) { + public ProductCommand.Register toCommand() { + return ProductCommand.Register.of(brandId, name, price, stockQuantity, description); + } } public record UpdateInfo( @@ -56,6 +60,9 @@ public record UpdateInfo( @Size(max = 1000, message = "상품 설명은 1,000자 이하여야 합니다") String description ) { + public ProductCommand.UpdateInfo toCommand() { + return ProductCommand.UpdateInfo.of(name, price, stockQuantity, description); + } } // Query diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java index 90b0b0517..f7efe6868 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java index aece24de8..9dae23c2c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductUserV1Controller.java @@ -2,9 +2,9 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.GetMapping; @@ -24,8 +24,9 @@ public class ProductUserV1Controller implements ProductUserApiV1Spec { @GetMapping @Override public ApiResponse> list( - ProductRequest.ListActive request) { - Page products = productFacade.getActiveList(request); + @Valid ProductRequest.ListActive request) { + Page products = productFacade.getActiveList( + request.brandId(), request.toPageable()); PageResponse pageResponse = PageResponse.from(products, ProductUserV1Dto.ProductResponse::from); return ApiResponse.success(pageResponse); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java index 711856cc8..2e534a2ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiV1Spec.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java similarity index 73% rename from apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java index 8c2000556..ff701b293 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java @@ -1,5 +1,6 @@ -package com.loopers.application.user; +package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserCommand; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -24,11 +25,17 @@ public record SignUp( @NotBlank(message = "이메일은 필수입니다") String email ) { + public UserCommand.SignUp toCommand() { + return UserCommand.SignUp.of(loginId, password, name, birthDate, email); + } } public record ChangePassword( @NotBlank(message = "새 비밀번호는 필수입니다") String newPassword ) { + public UserCommand.ChangePassword toCommand(Long id) { + return UserCommand.ChangePassword.of(id, newPassword); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 964cdac67..6407821ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,10 +2,10 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -25,8 +25,9 @@ public class UserV1Controller implements UserApiV1Spec { @PostMapping @Override - public ApiResponse signUp(@RequestBody UserRequest.SignUp request) { - UserInfo info = userFacade.signUp(request); + public ApiResponse signUp( + @RequestBody @Valid UserRequest.SignUp request) { + UserInfo info = userFacade.signUp(request.toCommand()); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } @@ -34,8 +35,8 @@ public ApiResponse signUp(@RequestBody UserRequest.SignU @Override public ApiResponse changePassword( @AuthUser AuthenticatedUser authUser, - @RequestBody UserRequest.ChangePassword request) { - userFacade.changePassword(authUser.id(), request); + @RequestBody @Valid UserRequest.ChangePassword request) { + userFacade.changePassword(request.toCommand(authUser.id())); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index 9579d0a56..ac1bf7da2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -42,7 +42,7 @@ class 브랜드_등록 { @Test void 유효한_정보로_등록하면_브랜드가_생성된다() { - Brand result = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand result = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); assertThat(result.getId()).isNotNull(); assertThat(result.getName()).isEqualTo("나이키"); @@ -51,9 +51,9 @@ class 브랜드_등록 { @Test void 이미_존재하는_브랜드명으로_등록하면_예외() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); - assertThatThrownBy(() -> brandService.register(BrandCommand.Create.of("나이키", "다른 설명"))) + assertThatThrownBy(() -> brandService.register(BrandCommand.Register.of("나이키", "다른 설명"))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -61,11 +61,11 @@ class 브랜드_등록 { @Test void 삭제된_브랜드와_동일한_이름으로_등록하면_예외() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); - assertThatThrownBy(() -> brandService.register(BrandCommand.Create.of("나이키", "새 설명"))) + assertThatThrownBy(() -> brandService.register(BrandCommand.Register.of("나이키", "새 설명"))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)) .hasMessageContaining("이미 등록된 브랜드입니다"); @@ -77,7 +77,7 @@ class 브랜드_수정 { @Test void 유효한_정보로_수정하면_성공한다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); Brand result = brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("아디다스", "독일 스포츠 브랜드")); @@ -95,7 +95,7 @@ class 브랜드_수정 { @Test void 삭제된_브랜드를_수정하면_예외() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); assertThatThrownBy(() -> brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("아디다스", null))) @@ -106,8 +106,8 @@ class 브랜드_수정 { @Test void 다른_브랜드와_이름이_중복이면_예외() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); assertThatThrownBy(() -> brandService.updateInfo(adidas.getId(), BrandCommand.UpdateInfo.of("나이키", null))) .isInstanceOf(CoreException.class) @@ -117,11 +117,11 @@ class 브랜드_수정 { @Test void 삭제된_브랜드와_이름이_중복이면_예외() { - Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand nike = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); nike.delete(); brandRepository.save(nike); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); assertThatThrownBy(() -> brandService.updateInfo(adidas.getId(), BrandCommand.UpdateInfo.of("나이키", null))) .isInstanceOf(CoreException.class) @@ -131,7 +131,7 @@ class 브랜드_수정 { @Test void 자기_자신_이름으로_수정하면_정상_처리된다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); Brand result = brandService.updateInfo(brand.getId(), BrandCommand.UpdateInfo.of("나이키", "변경된 설명")); @@ -146,7 +146,7 @@ class 브랜드_삭제 { @Test void 활성_브랜드를_삭제하면_삭제_상태로_변경된다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); @@ -156,7 +156,7 @@ class 브랜드_삭제 { @Test void 이미_삭제된_브랜드를_삭제해도_성공한다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brandService.delete(brand.getId()); assertThatCode(() -> brandService.delete(brand.getId())) @@ -185,7 +185,7 @@ class 브랜드_조회 { @Test void 삭제된_브랜드도_조회된다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); @@ -201,9 +201,9 @@ class 브랜드_목록_조회 { @Test void 조건_없이_조회하면_전체_브랜드가_최신순으로_반환된다() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - brandService.register(BrandCommand.Create.of("뉴발란스", "미국 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("뉴발란스", "미국 스포츠 브랜드")); Page result = brandService.findBrands(null, null, PageRequest.of(0, 20)); @@ -215,9 +215,9 @@ class 브랜드_목록_조회 { @Test void name_키워드로_검색하면_부분_일치하는_브랜드만_반환된다() { - brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); - brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); - brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키 에어", "에어 시리즈")); + brandService.register(BrandCommand.Register.of("나이키 조던", "조던 시리즈")); + brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findBrands("나이키", null, PageRequest.of(0, 20)); @@ -228,8 +228,8 @@ class 브랜드_목록_조회 { @Test void deleted_false면_활성_브랜드만_반환된다() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -241,8 +241,8 @@ class 브랜드_목록_조회 { @Test void deleted_true면_삭제된_브랜드만_반환된다() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -254,11 +254,11 @@ class 브랜드_목록_조회 { @Test void 복합_조건_name과_deleted_적용_시_모두_반영된다() { - brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); - Brand deletedNike = brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); + brandService.register(BrandCommand.Register.of("나이키 에어", "에어 시리즈")); + Brand deletedNike = brandService.register(BrandCommand.Register.of("나이키 조던", "조던 시리즈")); deletedNike.delete(); brandRepository.save(deletedNike); - brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findBrands("나이키", false, PageRequest.of(0, 20)); @@ -280,9 +280,9 @@ class 브랜드_일괄_조회 { @Test void ID_목록에_해당하는_브랜드들이_반환된다() { - Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); - brandService.register(BrandCommand.Create.of("뉴발란스", "미국 스포츠 브랜드")); + Brand nike = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("뉴발란스", "미국 스포츠 브랜드")); List result = brandService.getBrands(List.of(nike.getId(), adidas.getId())); @@ -300,7 +300,7 @@ class 브랜드_일괄_조회 { @Test void 존재하지_않는_ID가_포함되면_존재하는_것만_반환된다() { - Brand nike = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand nike = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); List result = brandService.getBrands(List.of(nike.getId(), 999L)); @@ -314,7 +314,7 @@ class 활성_브랜드_조회 { @Test void 활성_브랜드를_조회하면_성공한다() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); Brand result = brandService.getActiveBrand(brand.getId()); @@ -324,7 +324,7 @@ class 활성_브랜드_조회 { @Test void 삭제된_브랜드를_조회하면_예외() { - Brand brand = brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); + Brand brand = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); brand.delete(); brandRepository.save(brand); @@ -348,9 +348,9 @@ class 활성_브랜드_목록_조회 { @Test void 활성_브랜드만_이름_오름차순으로_반환된다() { - brandService.register(BrandCommand.Create.of("다나이키", "스포츠 브랜드")); - brandService.register(BrandCommand.Create.of("가아디다스", "독일 스포츠 브랜드")); - brandService.register(BrandCommand.Create.of("나뉴발란스", "미국 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("다나이키", "스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("가아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나뉴발란스", "미국 스포츠 브랜드")); Page result = brandService.findActiveBrands(null, PageRequest.of(0, 20)); @@ -362,8 +362,8 @@ class 활성_브랜드_목록_조회 { @Test void 삭제된_브랜드는_제외된다() { - brandService.register(BrandCommand.Create.of("나이키", "스포츠 브랜드")); - Brand adidas = brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")); + Brand adidas = brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); adidas.delete(); brandRepository.save(adidas); @@ -375,9 +375,9 @@ class 활성_브랜드_목록_조회 { @Test void name_키워드로_검색하면_활성_브랜드_중_부분_일치하는_것만_반환된다() { - brandService.register(BrandCommand.Create.of("나이키 에어", "에어 시리즈")); - brandService.register(BrandCommand.Create.of("나이키 조던", "조던 시리즈")); - brandService.register(BrandCommand.Create.of("아디다스", "독일 스포츠 브랜드")); + brandService.register(BrandCommand.Register.of("나이키 에어", "에어 시리즈")); + brandService.register(BrandCommand.Register.of("나이키 조던", "조던 시리즈")); + brandService.register(BrandCommand.Register.of("아디다스", "독일 스포츠 브랜드")); Page result = brandService.findActiveBrands("나이키", PageRequest.of(0, 20)); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 3a2047970..da8edd3dc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -48,7 +48,7 @@ class 상품_등록 { @Test void 유효한_정보로_등록하면_상품이_생성된다() { - Product result = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product result = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); assertThat(result.getId()).isNotNull(); assertThat(result.getBrandId()).isEqualTo(1L); @@ -65,7 +65,7 @@ class 상품_수정 { @Test void 유효한_정보로_수정하면_성공한다() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); Product result = productService.updateInfo(product.getId(), ProductCommand.UpdateInfo.of("런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); @@ -85,7 +85,7 @@ class 상품_수정 { @Test void 삭제된_상품을_수정하면_예외() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); @@ -101,7 +101,7 @@ class 상품_삭제 { @Test void 활성_상품을_삭제하면_삭제_상태로_변경된다() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); @@ -119,7 +119,7 @@ class 상품_삭제 { @Test void 이미_삭제된_상품을_다시_삭제해도_정상_처리된다() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); assertThatCode(() -> productService.delete(product.getId())) @@ -137,9 +137,9 @@ class 상품_목록_조회 { @Test void 조건_없이_조회하면_전체_상품을_최신_등록순으로_페이징하여_반환한다() { - productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); - productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); - productService.register(ProductCommand.Create.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); + productService.register(ProductCommand.Register.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Register.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Register.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); Page result = productService.findProducts(null, null, null, DEFAULT_PAGEABLE); @@ -152,8 +152,8 @@ class 상품_목록_조회 { @Test void 삭제된_상품도_포함하여_반환한다() { - productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); - Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Register.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Register.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -166,9 +166,9 @@ class 상품_목록_조회 { @Test void name_키워드로_검색하면_상품명에_해당_키워드가_포함된_상품만_반환한다() { - productService.register(ProductCommand.Create.of(1L, "런닝화", new BigDecimal("10000"), 10, "설명A")); - productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("20000"), 20, "설명B")); - productService.register(ProductCommand.Create.of(1L, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C")); + productService.register(ProductCommand.Register.of(1L, "런닝화", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Register.of(1L, "런닝 슈즈", new BigDecimal("30000"), 30, "설명C")); Page result = productService.findProducts("런닝", null, null, DEFAULT_PAGEABLE); @@ -179,9 +179,9 @@ class 상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드에_속한_상품만_반환한다() { - productService.register(ProductCommand.Create.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); - productService.register(ProductCommand.Create.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); - productService.register(ProductCommand.Create.of(1L, "나이키 런닝화", new BigDecimal("30000"), 30, "설명")); + productService.register(ProductCommand.Register.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Register.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); + productService.register(ProductCommand.Register.of(1L, "나이키 런닝화", new BigDecimal("30000"), 30, "설명")); Page result = productService.findProducts(null, 1L, null, DEFAULT_PAGEABLE); @@ -192,8 +192,8 @@ class 상품_목록_조회 { @Test void deleted_true로_필터링하면_삭제된_상품만_반환한다() { - productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); - Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Register.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Register.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -206,8 +206,8 @@ class 상품_목록_조회 { @Test void deleted_false로_필터링하면_활성_상품만_반환한다() { - productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); - Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + productService.register(ProductCommand.Register.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + Product deleted = productService.register(ProductCommand.Register.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); deleted.delete(); productRepository.save(deleted); @@ -220,11 +220,11 @@ class 상품_목록_조회 { @Test void 복합_필터를_동시에_적용할_수_있다() { - productService.register(ProductCommand.Create.of(1L, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명")); - Product deleted = productService.register(ProductCommand.Create.of(1L, "나이키 조던", new BigDecimal("20000"), 20, "설명")); + productService.register(ProductCommand.Register.of(1L, "나이키 에어맥스", new BigDecimal("10000"), 10, "설명")); + Product deleted = productService.register(ProductCommand.Register.of(1L, "나이키 조던", new BigDecimal("20000"), 20, "설명")); deleted.delete(); productRepository.save(deleted); - productService.register(ProductCommand.Create.of(2L, "나이키 콜라보", new BigDecimal("30000"), 30, "설명")); + productService.register(ProductCommand.Register.of(2L, "나이키 콜라보", new BigDecimal("30000"), 30, "설명")); Page result = productService.findProducts("나이키", 1L, false, DEFAULT_PAGEABLE); @@ -246,7 +246,7 @@ class 활성_상품_조회 { @Test void 활성_상품을_조회하면_성공한다() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); Product result = productService.getActiveProduct(product.getId()); @@ -256,7 +256,7 @@ class 활성_상품_조회 { @Test void 삭제된_상품을_조회하면_예외() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); @@ -280,9 +280,9 @@ class 활성_상품_목록_조회 { @Test void 조건_없이_조회하면_활성_상품만_최신순으로_반환한다() { - productService.register(ProductCommand.Create.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); - productService.register(ProductCommand.Create.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); - Product deleted = productService.register(ProductCommand.Create.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); + productService.register(ProductCommand.Register.of(1L, "운동화A", new BigDecimal("10000"), 10, "설명A")); + productService.register(ProductCommand.Register.of(1L, "운동화B", new BigDecimal("20000"), 20, "설명B")); + Product deleted = productService.register(ProductCommand.Register.of(1L, "운동화C", new BigDecimal("30000"), 30, "설명C")); deleted.delete(); productRepository.save(deleted); @@ -296,8 +296,8 @@ class 활성_상품_목록_조회 { @Test void brandId로_필터링하면_해당_브랜드의_활성_상품만_반환한다() { - productService.register(ProductCommand.Create.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); - productService.register(ProductCommand.Create.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); + productService.register(ProductCommand.Register.of(1L, "나이키 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Register.of(2L, "아디다스 운동화", new BigDecimal("20000"), 20, "설명")); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); Page result = productService.findActiveProducts(1L, pageable); @@ -308,9 +308,9 @@ class 활성_상품_목록_조회 { @Test void 가격_오름차순으로_정렬할_수_있다() { - productService.register(ProductCommand.Create.of(1L, "비싼 운동화", new BigDecimal("90000"), 10, "설명")); - productService.register(ProductCommand.Create.of(1L, "싼 운동화", new BigDecimal("10000"), 10, "설명")); - productService.register(ProductCommand.Create.of(1L, "중간 운동화", new BigDecimal("50000"), 10, "설명")); + productService.register(ProductCommand.Register.of(1L, "비싼 운동화", new BigDecimal("90000"), 10, "설명")); + productService.register(ProductCommand.Register.of(1L, "싼 운동화", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Register.of(1L, "중간 운동화", new BigDecimal("50000"), 10, "설명")); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "price")); Page result = productService.findActiveProducts(null, pageable); @@ -321,9 +321,9 @@ class 활성_상품_목록_조회 { @Test void 좋아요_내림차순으로_정렬할_수_있다() { - Product p1 = productService.register(ProductCommand.Create.of(1L, "인기 상품", new BigDecimal("10000"), 10, "설명")); - productService.register(ProductCommand.Create.of(1L, "보통 상품", new BigDecimal("20000"), 20, "설명")); - Product p3 = productService.register(ProductCommand.Create.of(1L, "최고 인기", new BigDecimal("30000"), 30, "설명")); + Product p1 = productService.register(ProductCommand.Register.of(1L, "인기 상품", new BigDecimal("10000"), 10, "설명")); + productService.register(ProductCommand.Register.of(1L, "보통 상품", new BigDecimal("20000"), 20, "설명")); + Product p3 = productService.register(ProductCommand.Register.of(1L, "최고 인기", new BigDecimal("30000"), 30, "설명")); p1.incrementLikeCount(); p1.incrementLikeCount(); productRepository.save(p1); @@ -354,8 +354,8 @@ class 브랜드별_상품_일괄_삭제 { @Test void 해당_브랜드의_활성_상품이_모두_삭제_상태로_변경된다() { - Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product product2 = productService.register(ProductCommand.Create.of(1L, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); + Product product1 = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Register.of(1L, "런닝화", new BigDecimal("60000"), 200, "가벼운 런닝화")); productService.deleteAllByBrandId(1L); @@ -373,7 +373,7 @@ class 브랜드별_상품_일괄_삭제 { @Test void 이미_삭제된_상품도_삭제_상태를_유지한다() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); productService.delete(product.getId()); productService.deleteAllByBrandId(1L); @@ -384,8 +384,8 @@ class 브랜드별_상품_일괄_삭제 { @Test void 다른_브랜드의_상품은_영향받지_않는다() { - productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product otherBrandProduct = productService.register(ProductCommand.Create.of(2L, "샌들", new BigDecimal("30000"), 50, "여름 샌들")); + productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product otherBrandProduct = productService.register(ProductCommand.Register.of(2L, "샌들", new BigDecimal("30000"), 50, "여름 샌들")); productService.deleteAllByBrandId(1L); @@ -399,8 +399,8 @@ class 재고_일괄_차감 { @Test void 유효한_상품에_재고를_차감하면_차감된다() { - Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product product2 = productService.register(ProductCommand.Create.of(1L, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠")); + Product product1 = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Register.of(1L, "셔츠", new BigDecimal("30000"), 50, "멋진 셔츠")); productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 5)); @@ -412,7 +412,7 @@ class 재고_일괄_차감 { @Test void 미존재_상품이_포함되면_예외() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 10, 999L, 5))) .isInstanceOf(CoreException.class) @@ -422,7 +422,7 @@ class 재고_일괄_차감 { @Test void 삭제된_상품이_포함되면_예외() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); product.delete(); productRepository.save(product); @@ -434,7 +434,7 @@ class 재고_일괄_차감 { @Test void 재고가_부족한_상품이_있으면_예외() { - Product product = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화")); + Product product = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 10, "편한 운동화")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product.getId(), 11))) .isInstanceOf(CoreException.class) @@ -444,8 +444,8 @@ class 재고_일괄_차감 { @Test void 재고_부족_시_어떤_상품의_재고도_차감되지_않는다() { - Product product1 = productService.register(ProductCommand.Create.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); - Product product2 = productService.register(ProductCommand.Create.of(1L, "셔츠", new BigDecimal("30000"), 5, "멋진 셔츠")); + Product product1 = productService.register(ProductCommand.Register.of(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화")); + Product product2 = productService.register(ProductCommand.Register.of(1L, "셔츠", new BigDecimal("30000"), 5, "멋진 셔츠")); assertThatThrownBy(() -> productService.deductStocks(Map.of(product1.getId(), 10, product2.getId(), 10))) .isInstanceOf(CoreException.class); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java index 93f8782c1..7cc6d5d8f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.ProductAdminV1Dto; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java index 2ffa05e39..a0ab226b0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.support.E2ETestFixture; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index 057ad185e..05c342b4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.ProductAdminV1Dto; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java index c8d81598b..c7a1e0f42 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.support.E2ETestFixture; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index d18a00fda..49d2020e3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.E2ETestFixture; import com.loopers.utils.DatabaseCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java index 330b7b856..76610c24d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/E2ETestFixture.java @@ -1,8 +1,8 @@ package com.loopers.support; -import com.loopers.application.brand.BrandRequest; -import com.loopers.application.product.ProductRequest; -import com.loopers.application.user.UserRequest; +import com.loopers.interfaces.api.brand.BrandRequest; +import com.loopers.interfaces.api.product.ProductRequest; +import com.loopers.interfaces.api.user.UserRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.product.ProductAdminV1Dto; From 7e932aac9ea274b37d00c98dc15216356e32df91 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 3 Mar 2026 15:02:05 +0900 Subject: [PATCH 64/68] =?UTF-8?q?chore:=20Request=20DTO=EB=A5=BC=20interfa?= =?UTF-8?q?ces=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EA=B3=A0=20Command?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20Request=EC=99=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/conventions/dto.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/rules/conventions/dto.md b/.claude/rules/conventions/dto.md index 9c23bb6d5..3c297ce80 100644 --- a/.claude/rules/conventions/dto.md +++ b/.claude/rules/conventions/dto.md @@ -96,6 +96,14 @@ public record OrderCommand() { |------|------|-------------| | `Create`, `Update` 등 | Facade → Service | 비즈니스 데이터 포함 (이름, 가격 등) | +## Command 네이밍 규칙 + +| 생성 경로 | 이름 기준 | 예시 | +|----------|----------|------| +| `toCommand()`로 생성 (Facade 진입) | Request 이름과 동일 | `Place`, `Register`, `Cancel` | +| Facade가 보강하여 생성 (Service 진입) | 도메인 동작 | `Create`, `Update` | +| 보강 불필요 시 | 하나의 Command가 관통 | `SignUp`, `ChangePassword` | + ## Info 구조 Info는 도메인별로 하나의 record에 중첩 record로 정의한다. From 595ba0da1f4d3aa30bf00b2c4b9f66bf118e1fcc Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 4 Mar 2026 14:19:29 +0900 Subject: [PATCH 65/68] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EC=84=A0=EC=96=B8=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EB=AC=B8=20=EB=82=A0=EC=A7=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20Controller=20@Valid=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/project/architecture.md | 6 ++---- .../com/loopers/application/brand/BrandService.java | 7 ++++++- .../com/loopers/application/like/LikeService.java | 2 +- .../com/loopers/application/order/OrderFacade.java | 11 +---------- .../com/loopers/application/order/OrderService.java | 4 +++- .../loopers/application/product/ProductService.java | 6 +++++- .../com/loopers/application/user/UserService.java | 3 ++- .../loopers/interfaces/api/order/OrderRequest.java | 7 +++++++ .../interfaces/api/order/OrderV1Controller.java | 2 +- 9 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index 50a49a1f1..5b8e2261a 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -102,8 +102,6 @@ interfaces → application → domain ← infrastructure - 구현체는 `infrastructure/` 패키지에 배치 ### 트랜잭션 전략 -- **ApplicationService**: 클래스 레벨 `@Transactional(readOnly = true)` 기본 적용 - - 재사용 단위로 조회 메서드가 압도적 — 명령 메서드만 `@Transactional`로 오버라이드 -- **Facade**: 클래스 레벨 `@Transactional` 선언 금지 — 모든 메서드에 개별 선언 - - 유스케이스 단위로 읽기/쓰기 비율 예측 불가 — `@Transactional` 또는 `@Transactional(readOnly = true)` 명시 +- 클래스 레벨 `@Transactional` 선언 금지 — 모든 메서드에 개별 선언 + - 명령: `@Transactional`, 조회: `@Transactional(readOnly = true)` - Facade가 있으면 ApplicationService의 트랜잭션은 기존 트랜잭션에 참여 (REQUIRED) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 4da2d9786..a88b50070 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -18,7 +18,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class BrandService { private final BrandRepository brandRepository; @@ -57,28 +56,34 @@ public void delete(Long brandId) { // Query + @Transactional(readOnly = true) public Brand getBrand(Long brandId) { return brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); } + @Transactional(readOnly = true) public Page findBrands(String name, Boolean deleted, Pageable pageable) { return brandRepository.findAll(name, deleted, pageable); } + @Transactional(readOnly = true) public Brand getActiveBrand(Long brandId) { return brandRepository.findActiveById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다")); } + @Transactional(readOnly = true) public List getBrands(List brandIds) { return brandRepository.findAllByIdIn(brandIds); } + @Transactional(readOnly = true) public Page findActiveBrands(String name, Pageable pageable) { return brandRepository.findAllActive(name, pageable); } + @Transactional(readOnly = true) public Map getBrandsMapByIds(Set brandIds) { return brandRepository.findAllByIdIn(brandIds).stream() .collect(Collectors.toMap(Brand::getId, Function.identity())); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 5270bfc12..e0e31d053 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -12,7 +12,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class LikeService { private final LikeRepository likeRepository; @@ -44,6 +43,7 @@ public boolean unlike(Long userId, Long productId) { // Query + @Transactional(readOnly = true) public Page findLikedActiveProducts(Long userId, Pageable pageable) { return likeRepository.findAllByUserIdWithActiveProduct(userId, pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4154dd319..c27983cbc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -11,8 +11,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -79,14 +77,7 @@ public OrderInfo getOrderDetail(Long userId, Long orderId) { } @Transactional(readOnly = true) - public Page getOrderList(Long userId, LocalDate startDate, LocalDate endDate, Pageable pageable) { - if (startDate != null && endDate != null && startDate.isAfter(endDate)) { - throw new CoreException(ErrorType.BAD_REQUEST, "시작일은 종료일 이전이어야 합니다"); - } - ZonedDateTime startDateTime = startDate != null - ? startDate.atStartOfDay(ZoneId.systemDefault()) : null; - ZonedDateTime endDateTime = endDate != null - ? endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()) : null; + public Page getOrderList(Long userId, ZonedDateTime startDateTime, ZonedDateTime endDateTime, Pageable pageable) { Page orders = orderService.findOrdersByUserIdAndDateRange(userId, startDateTime, endDateTime, pageable); return orders.map(OrderInfo.OrderSummary::from); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index b4b7dcd75..2e5dd2d73 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -14,7 +14,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class OrderService { private final OrderRepository orderRepository; @@ -39,15 +38,18 @@ public Order createOrder(OrderCommand.Create command) { // Query + @Transactional(readOnly = true) public Order getOrder(Long orderId) { return orderRepository.findByIdWithItems(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다")); } + @Transactional(readOnly = true) public Page findOrdersByUserIdAndDateRange(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); } + @Transactional(readOnly = true) public Page findAllOrders(Pageable pageable) { return orderRepository.findAll(pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 6fe87df4a..cf79934cf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -19,7 +19,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class ProductService { private final ProductRepository productRepository; @@ -87,24 +86,29 @@ public void deleteAllByBrandId(Long brandId) { // Query + @Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); } + @Transactional(readOnly = true) public Product getActiveProduct(Long productId) { return productRepository.findActiveById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); } + @Transactional(readOnly = true) public Page findProducts(String name, Long brandId, Boolean deleted, Pageable pageable) { return productRepository.findAll(name, brandId, deleted, pageable); } + @Transactional(readOnly = true) public Page findActiveProducts(Long brandId, Pageable pageable) { return productRepository.findAllActive(brandId, pageable); } + @Transactional(readOnly = true) public Map getProductsMapByIds(Set productIds) { return productRepository.findAllByIdIn(productIds).stream() .collect(Collectors.toMap(Product::getId, Function.identity())); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index a195c9581..84722796c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -11,7 +11,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; @@ -39,11 +38,13 @@ public void changePassword(UserCommand.ChangePassword command) { // Query + @Transactional(readOnly = true) public User getById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다")); } + @Transactional(readOnly = true) public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다")); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java index 9f2391f4e..5f6788db9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java @@ -2,6 +2,7 @@ import com.loopers.application.order.OrderCommand; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -58,6 +59,12 @@ public record ListByUser( size = Objects.requireNonNullElse(size, 20); } + @AssertTrue(message = "시작일은 종료일 이전이어야 합니다") + public boolean isStartDateBeforeEndDate() { + if (startDate == null || endDate == null) return true; + return !startDate.isAfter(endDate); + } + public Pageable toPageable() { return PageRequest.of(page, size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 09d096a1b..402edb46e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -51,7 +51,7 @@ public ApiResponse> listOrders( @AuthUser AuthenticatedUser user, @Valid OrderRequest.ListByUser request) { Page orders = orderFacade.getOrderList( - user.id(), request.startDate(), request.endDate(), request.toPageable()); + user.id(), request.startDateTime(), request.endDateTime(), request.toPageable()); PageResponse pageResponse = PageResponse.from(orders, OrderV1Dto.OrderListResponse::from); return ApiResponse.success(pageResponse); } From f75ff3691f12c54c0172faf0afad7c1ff3fd23f0 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 4 Mar 2026 14:43:12 +0900 Subject: [PATCH 66/68] =?UTF-8?q?refactor:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=9D=BD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/product/ProductService.java | 6 +++--- .../loopers/domain/product/ProductRepository.java | 2 -- .../infrastructure/product/ProductJpaRepository.java | 12 ------------ .../product/ProductRepositoryImpl.java | 10 ---------- 4 files changed, 3 insertions(+), 27 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index cf79934cf..7d3ffee0b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -50,14 +50,14 @@ public void delete(Long productId) { @Transactional public void incrementLikeCount(Long productId) { - Product product = productRepository.findByIdForUpdate(productId) + Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); product.incrementLikeCount(); } @Transactional public void decrementLikeCount(Long productId) { - Product product = productRepository.findByIdForUpdate(productId) + Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다")); product.decrementLikeCount(); } @@ -65,7 +65,7 @@ public void decrementLikeCount(Long productId) { @Transactional public List deductStocks(Map productQuantities) { List productIds = new ArrayList<>(productQuantities.keySet()); - List products = productRepository.findAllByIdInForUpdate(productIds); + List products = productRepository.findAllByIdIn(productIds); if (products.size() != productIds.size()) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index ab3a9a8d9..837d406ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,11 +15,9 @@ public interface ProductRepository { // Query Optional findById(Long id); Optional findActiveById(Long id); - Optional findByIdForUpdate(Long id); List findAllByBrandId(Long brandId); List findAllByIdIn(Collection ids); - List findAllByIdInForUpdate(List ids); Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable); Page findAllActive(Long brandId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index a508b8430..29cb74ff9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -7,9 +7,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.Lock; - import java.util.Collection; import java.util.List; import java.util.Optional; @@ -20,19 +17,10 @@ public interface ProductJpaRepository extends JpaRepository { @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") Optional findActiveById(@Param("id") Long id); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Product p WHERE p.id = :id") - Optional findByIdForUpdate(@Param("id") Long id); - List findAllByIdIn(Collection ids); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Product p WHERE p.id IN :ids") - List findAllByIdInForUpdate(@Param("ids") List ids); - List findAllByBrandId(Long brandId); - @Query(value = "SELECT p FROM Product p " + "WHERE (:name IS NULL OR p.name LIKE %:name%) " + "AND (:brandId IS NULL OR p.brandId = :brandId) " diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 5ca76750d..ea7ade613 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -34,11 +34,6 @@ public Optional findActiveById(Long id) { return productJpaRepository.findActiveById(id); } - @Override - public Optional findByIdForUpdate(Long id) { - return productJpaRepository.findByIdForUpdate(id); - } - @Override public List findAllByBrandId(Long brandId) { return productJpaRepository.findAllByBrandId(brandId); @@ -49,11 +44,6 @@ public List findAllByIdIn(Collection ids) { return productJpaRepository.findAllByIdIn(ids); } - @Override - public List findAllByIdInForUpdate(List ids) { - return productJpaRepository.findAllByIdInForUpdate(ids); - } - @Override public Page findAll(String name, Long brandId, Boolean deleted, Pageable pageable) { return productJpaRepository.findAll(name, brandId, deleted, pageable); From c251ed16169dbac614f3badb06e08024d4a50365 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 4 Mar 2026 16:28:49 +0900 Subject: [PATCH 67/68] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=8B=9C=20=ED=99=9C=EC=84=B1=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 2 -- .../com/loopers/domain/product/Product.java | 1 - .../loopers/domain/product/ProductTest.java | 10 +++--- .../interfaces/api/like/LikeApiE2ETest.java | 32 +++++++------------ .../like/sequences/002-product-unlike.md | 9 ++---- docs/requirements/like.md | 4 --- docs/specs/like/002-product-unlike.md | 4 +-- 7 files changed, 21 insertions(+), 41 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index f5959924d..2e61aa522 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -39,8 +39,6 @@ public void like(Long userId, Long productId) { @Transactional public void unlike(Long userId, Long productId) { - productService.getActiveProduct(productId); - boolean deleted = likeService.unlike(userId, productId); if (deleted) { productService.decrementLikeCount(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index a86ef84d3..babeb4466 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -65,7 +65,6 @@ public void incrementLikeCount() { } public void decrementLikeCount() { - validateNotDeleted(); if (this.likeCount > 0) { this.likeCount--; } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index ec970096d..5acc2a673 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -383,14 +383,14 @@ class 좋아요_감소 { } @Test - void 삭제된_상품이면_예외() { + void 삭제된_상품도_좋아요수가_감소한다() { Product product = Product.create(1L, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + product.incrementLikeCount(); product.delete(); - assertThatThrownBy(() -> product.decrementLikeCount()) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)) - .hasMessageContaining("존재하지 않는 상품입니다"); + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 181835fc1..aef7b617f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -215,40 +215,32 @@ class 좋아요_취소 { } @Test - void 삭제된_상품에_좋아요_취소하면_404_응답() { + void 삭제된_상품에_좋아요_취소하면_200_응답하고_좋아요가_존재하면_삭제하고_likeCount를_감소한다() { fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); Long brandId = fixture.registerBrand("나이키", "스포츠 브랜드"); Long productId = fixture.registerProduct(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화"); + postLike(productId); fixture.deleteProduct(productId); - ResponseEntity> response = testRestTemplate.exchange( - LIKE_ENDPOINT, HttpMethod.DELETE, - new HttpEntity<>(userHeaders()), - new ParameterizedTypeReference<>() {}, - productId - ); + ResponseEntity> response = deleteLike(productId); assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), - () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + ResponseEntity>> productResponse = + getProductList("?status=DELETED"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + } ); } @Test - void 미존재_상품에_좋아요_취소하면_404_응답() { + void 미존재_상품에_좋아요_취소하면_200_응답하고_상태_유지() { fixture.signUp(LOGIN_ID, LOGIN_PW, "홍길동", "test@example.com"); - ResponseEntity> response = testRestTemplate.exchange( - LIKE_ENDPOINT, HttpMethod.DELETE, - new HttpEntity<>(userHeaders()), - new ParameterizedTypeReference<>() {}, - 999L - ); + ResponseEntity> response = deleteLike(999L); - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), - () -> assertThat(response.getBody().meta().message()).contains("존재하지 않는 상품입니다") - ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test diff --git a/docs/design/like/sequences/002-product-unlike.md b/docs/design/like/sequences/002-product-unlike.md index 6729f14cc..8ff3ff332 100644 --- a/docs/design/like/sequences/002-product-unlike.md +++ b/docs/design/like/sequences/002-product-unlike.md @@ -19,11 +19,6 @@ sequenceDiagram activate LF critical @Transactional - LF->>PS: 활성 상품 확인 - activate PS - PS-->>LF: 완료 - deactivate PS - LF->>LS: 좋아요 삭제 activate LS LS-->>LF: 삭제 여부 (boolean) @@ -46,8 +41,8 @@ sequenceDiagram ## 핵심 포인트 - 트랜잭션 범위: Facade 메서드 전체를 `@Transactional`로 감싼다 -- 활성 상품 확인: ProductService가 미존재/삭제 시 예외를 던진다 (Facade는 분기하지 않음) +- 활성 상품 검증 없음: 삭제된 상품도 좋아요 취소 가능 (좋아요가 걸린 채 해제 불가능한 상태 방지) - 좋아요 삭제 캡슐화: LikeService가 존재 여부 확인 + 물리적 삭제를 캡슐화한다. Facade는 도메인 내부 상태(Optional 등)를 직접 다루지 않는다 -- 멱등성: 좋아요가 없으면 삭제/감소 없이 200 응답한다 +- 멱등성: 좋아요가 없거나 상품이 미존재하면 삭제/감소 없이 200 응답한다 - 좋아요 데이터는 물리적으로 삭제한다 (Soft Delete 아님) - 좋아요 삭제와 likeCount 감소는 같은 트랜잭션에서 처리한다 diff --git a/docs/requirements/like.md b/docs/requirements/like.md index 5f765962f..d15fdf327 100644 --- a/docs/requirements/like.md +++ b/docs/requirements/like.md @@ -39,16 +39,12 @@ ### [LK-02] 상품 좋아요 취소 -- **Precondition** - - 대상 상품이 존재해야 한다 - **Main Flow** 1. 사용자가 좋아요 취소를 요청한다 2. 해당 상품의 좋아요 존재 여부를 확인한다 3. 해당 상품의 좋아요를 물리적으로 삭제한다 - **Alternate Flow** - **[이미 취소 상태]**: 좋아요가 없는 상품일 경우 현재 상태를 유지한다 -- **Exception Flow** - - **[상품 미존재]**: 상품이 미존재할 경우 취소를 거부하고 "존재하지 않는 상품입니다" 안내를 노출한다 ### [LK-03] 좋아요 상품 목록 조회 diff --git a/docs/specs/like/002-product-unlike.md b/docs/specs/like/002-product-unlike.md index c0083cb24..dc285c304 100644 --- a/docs/specs/like/002-product-unlike.md +++ b/docs/specs/like/002-product-unlike.md @@ -22,8 +22,8 @@ User - [ ] 좋아요 취소 시 해당 상품의 likeCount가 1 감소한다 - [ ] 좋아요 취소 시 좋아요 데이터를 물리적으로 삭제한다 - [ ] 좋아요하지 않은 상품에 취소 요청하면 200 응답, 상태 유지 (likeCount 변동 없음) -- [ ] 삭제된 상품에 좋아요 취소하면 404 응답, 메시지: "존재하지 않는 상품입니다" -- [ ] 해당 ID의 상품 데이터가 존재하지 않으면 404 응답, 메시지: "존재하지 않는 상품입니다" +- [ ] 삭제된 상품에 좋아요 취소하면 200 응답, 좋아요가 존재하면 삭제하고 likeCount를 감소한다 +- [ ] 해당 ID의 상품 데이터가 존재하지 않으면 200 응답, 상태 유지 - [ ] 인증 헤더가 누락되면 401 응답, 메시지: "인증 헤더가 필요합니다" - [ ] 인증에 실패하면 401 응답, 메시지: "인증에 실패했습니다" From c10c6c4abc1fa9346d4eee3887e575cba88bd076 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 4 Mar 2026 17:05:00 +0900 Subject: [PATCH 68/68] =?UTF-8?q?fix:=20ERD=20ORDER=5FITEM=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20brand=5Fname=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/erd.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/design/erd.md b/docs/design/erd.md index 2ffb529f1..ee97f0ed9 100644 --- a/docs/design/erd.md +++ b/docs/design/erd.md @@ -60,7 +60,6 @@ erDiagram bigint order_id FK "소속 주문" bigint product_id "원본 상품 ID" varchar product_name "상품명 스냅샷" - varchar brand_name "브랜드명 스냅샷" decimal price "단가 스냅샷" int quantity "주문 수량" datetime created_at