From ec1fdcfab36242ae504f130d84b85ae2cc337c13 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:05:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md 프로젝트 규칙 문서 추가 - 요구사항, 시퀀스, 클래스, ERD 설계 문서 작성 - ErrorType FORBIDDEN, CONFLICT 추가 - AdminValidator, PasswordEncoderConfig 추가 - ApiControllerAdvice 예외 처리 보강 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + CLAUDE.md | 200 +++ apps/commerce-api/build.gradle.kts | 4 + .../application/example/ExampleFacade.java | 2 + .../domain/example/ExampleService.java | 3 +- .../interfaces/api/ApiControllerAdvice.java | 18 + .../loopers/support/auth/AdminValidator.java | 17 + .../support/config/PasswordEncoderConfig.java | 15 + .../com/loopers/support/error/ErrorType.java | 2 + .../support/auth/AdminValidatorTest.java | 77 + .../src/test/resources/docker-java.properties | 1 + build.gradle.kts | 1 + claude/skills/requirements-analysis/SKILL.md | 77 + docs/design/01-requirements.md | 593 +++++++ docs/design/02-sequence-diagram.md | 915 ++++++++++ docs/design/03-class-diagram.md | 1577 +++++++++++++++++ docs/design/04-erd.md | 145 ++ docs/member-erd.md | 34 + docs/member-profile-lookup-design.md | 174 ++ docs/member-signup-design.md | 264 +++ gradle.properties | 1 + 21 files changed, 4122 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/AdminValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/support/auth/AdminValidatorTest.java create mode 100644 apps/commerce-api/src/test/resources/docker-java.properties create mode 100644 claude/skills/requirements-analysis/SKILL.md create mode 100644 docs/design/01-requirements.md create mode 100644 docs/design/02-sequence-diagram.md create mode 100644 docs/design/03-class-diagram.md create mode 100644 docs/design/04-erd.md create mode 100644 docs/member-erd.md create mode 100644 docs/member-profile-lookup-design.md create mode 100644 docs/member-signup-design.md diff --git a/.gitignore b/.gitignore index 5a979af6f..1a1ad415a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Study ### +docs/study/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..22d6e0272 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,200 @@ +# CLAUDE.md + +## 프로젝트 개요 + +Spring Boot 기반 커머스 멀티모듈 템플릿 프로젝트 (`loopers-java-spring-template`). +REST API, 배치 처리, 이벤트 스트리밍을 위한 마이크로서비스 아키텍처 패턴을 제공한다. + +## 기술 스택 및 버전 + +| 구분 | 기술 | 버전 | +|------|------|------| +| Language | Java | 21 | +| Language | Kotlin | 2.0.20 | +| Framework | Spring Boot | 3.4.4 | +| Framework | Spring Cloud | 2024.0.1 | +| Dependency Mgmt | spring-dependency-management | 1.1.7 | +| Database | MySQL | 8.0 | +| ORM | Spring Data JPA + QueryDSL | (Spring 관리) | +| Cache | Redis (Master-Replica) | 7.0 | +| Messaging | Apache Kafka (KRaft) | 3.5.1 | +| API Docs | SpringDoc OpenAPI | 2.7.0 | +| Monitoring | Micrometer + Prometheus | (Spring 관리) | +| Tracing | Micrometer Brave | (Spring 관리) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockK 4.0.2, Instancio 5.0.2 | +| Testing Infra | TestContainers (MySQL, Redis, Kafka) | (Spring 관리) | +| Code Coverage | JaCoCo | (Gradle 관리) | +| Build Tool | Gradle (Kotlin DSL) | Wrapper 포함 | + +## 모듈 구조 + +``` +root +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api # REST API 서버 (Tomcat) +│ ├── commerce-batch # Spring Batch 배치 처리 +│ └── commerce-streamer # Kafka Consumer 스트리밍 서비스 +├── modules/ # 재사용 가능한 인프라 모듈 (java-library) +│ ├── jpa # JPA + MySQL + QueryDSL + HikariCP +│ ├── redis # Redis Master-Replica (Lettuce) +│ └── kafka # Kafka Producer/Consumer 설정 +├── supports/ # 횡단 관심사 모듈 +│ ├── jackson # Jackson ObjectMapper 커스터마이징 +│ ├── logging # 구조화 로깅 + Slack 연동 +│ └── monitoring # Prometheus 메트릭 + Health Probe +└── docker/ + ├── infra-compose.yml # MySQL, Redis, Kafka, Kafka UI + └── monitoring-compose.yml # Prometheus, Grafana +``` + +## 아키텍처 레이어 (commerce-api 기준) + +``` +interfaces/api/ → REST Controller, DTO, ApiSpec +application/ → Facade (유즈케이스 오케스트레이션), Info DTO +domain/ → Entity, Repository 인터페이스, Service (비즈니스 로직) +infrastructure/ → Repository 구현체 +support/error/ → CoreException, ErrorType +``` + +## 빌드 및 실행 + +```bash +# 인프라 구동 +docker compose -f docker/infra-compose.yml up -d + +# 모니터링 스택 (Grafana: localhost:3000, admin/admin) +docker compose -f docker/monitoring-compose.yml up -d + +# 빌드 +./gradlew clean build + +# 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=demoJob' +./gradlew :apps:commerce-streamer:bootRun + +# 테스트 +./gradlew test + +# 코드 커버리지 +./gradlew test jacocoTestReport +``` + +## 테스트 설정 + +- JUnit 5 기반, 테스트 프로파일: `test`, 타임존: `Asia/Seoul` +- TestContainers로 MySQL/Redis/Kafka 통합 테스트 +- 모듈별 `testFixtures`로 테스트 유틸리티 공유 (`DatabaseCleanUp`, `RedisCleanUp` 등) +- 테스트 병렬 실행 없음 (`maxParallelForks = 1`) + +## 프로파일 + +`local`, `test`, `dev`, `qa`, `prd` — 환경별 설정은 각 모듈의 yml 파일에서 프로파일 그룹으로 관리. +운영 환경은 환경변수로 주입: `MYSQL_HOST`, `REDIS_MASTER_HOST`, `BOOTSTRAP_SERVERS` 등. + +## 주요 패턴 + +- **BaseEntity**: ID 자동생성, `createdAt`/`updatedAt` 감사, `deletedAt` 소프트 삭제 +- **ApiResponse**: 통일된 응답 래퍼 (`meta.result`, `meta.errorCode`, `data`) +- **CoreException + ErrorType**: 타입 기반 에러 처리 (400, 404, 409, 500) +- **별도 관리 포트**: 메트릭/헬스체크는 8081 포트로 분리 +- **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK) +- **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리 + +## API 응답 규칙 +- 모든 응답은 `ApiResponse`로 래핑 +- 성공: `ApiResponse.success(data)` 반환 +- 실패: `CoreException(ErrorType)` throw → GlobalExceptionHandler에서 처리 +- 생성 API: `@ResponseStatus(HttpStatus.CREATED)` + +## 의존성 방향 (외부 → 내부) +``` +interfaces → application → domain ← infrastructure +``` +- domain 계층은 다른 계층에 의존하지 않음 +- infrastructure는 domain의 Repository 인터페이스를 구현 + +## 문서 작성 +### 다이어그램 작성 +- ERD, 시퀀스 다이어그램, 클래스 다이어그램 등 작성 시 mermaid를 이용한 마크다운으로 작성. + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. +- 구현은 한 단계씩 순서대로 진행 및 단계가 끝날 때 마다 핵심 개념/키워드 설명. +- API는 RESTFul API로 구현 +### 인증 요청 +- 유저 정보가 필요한 모든 요청은 아래 헤더를 통해 요청 +* X-Loopers-LoginId : 로그인 ID +* X-Loopers-LoginPw : 비밀번호 +- Admin 기능은 아래 헤더를 통해 Admin 식별 후 제공 +* X-Loopers-Ldap : loopers.admin + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 + +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 +- 객체지향 5원칙을 어기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 +- Domain Entity와 Persistence Entity는 구분하여 구현 +- 필요한 의존성은 적절히 관리하여 최소화 +- 통합 테스트는 테스트 컨테이너를 이용해 진행 +- 테스트 코드 작성 시 MIN, MAX, EDGE 케이스를 고려하여 작성 +- Lombok 활용이 가능한 부분은 Lombok을 활용하여 코드를 간결하게 작성 +- VO (Value Object) 활용이 가능한 부분은 VO를 활용하여 코드를 간결하게 작성하되 남발하지 말것 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 + +## 깃 커밋 컨벤션 +- feat: 새로운 기능 추가 +- fix: 버그 수정 +- docs: 문서만 수정 (예: README, 주석은 아님) +- style: 코드 포맷팅 (공백, 세미콜론 등 기능 변화 없음) +- refactor: 기능 변화 없이 코드 개선 +- test: 테스트 코드 추가/수정 +- chore: 빌드/패키지 설정 등 기능과 직접 관련 없는 작업 +- 커밋 메세지는 한국어로 작성할 것 + +## 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. + +## 아키텍처, 패키지 구성 전략 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성하도록 합니다. +- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태로 작성합니다. + - 예시 + > /interfaces/api (presentation 레이어 - API) + /application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공) + /domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치) + /infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공) \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..dae7d09ad 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,8 +6,12 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") 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 index 552a9ad62..e84f7e249 100644 --- 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 @@ -4,12 +4,14 @@ import com.loopers.domain.example.ExampleService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component public class ExampleFacade { private final ExampleService exampleService; + @Transactional(readOnly = true) 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/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java index c0e8431e8..0931824c6 100644 --- 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 @@ -4,6 +4,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -12,7 +13,7 @@ public class ExampleService { private final ExampleRepository exampleRepository; - @Transactional(readOnly = true) + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) 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/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..a004f5ca8 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,8 @@ 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.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -38,6 +40,13 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'이(가) 누락되었습니다.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); @@ -46,6 +55,15 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("입력값이 올바르지 않습니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AdminValidator.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AdminValidator.java new file mode 100644 index 000000000..33ba8c7af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AdminValidator.java @@ -0,0 +1,17 @@ +package com.loopers.support.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; + +@Component +public class AdminValidator { + + private static final String ADMIN_LDAP = "loopers.admin"; + + public void validate(String ldap) { + if (ldap == null || ldap.isEmpty() || !ADMIN_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..60dcc143f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..3d8086fe6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,8 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminValidatorTest.java new file mode 100644 index 000000000..a21387d3e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminValidatorTest.java @@ -0,0 +1,77 @@ +package com.loopers.support.auth; + +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.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +@DisplayName("AdminValidator 단위 테스트") +class AdminValidatorTest { + + private final AdminValidator adminValidator = new AdminValidator(); + + @Nested + @DisplayName("validate 메서드") + class Validate { + + @Test + @DisplayName("올바른 LDAP 값이면 예외가 발생하지 않는다") + void success_withValidLdap() { + // Arrange + String validLdap = "loopers.admin"; + + // Act & Assert + assertThatCode(() -> adminValidator.validate(validLdap)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("잘못된 LDAP 값이면 FORBIDDEN 예외가 발생한다") + void fail_withInvalidLdap() { + // Arrange + String invalidLdap = "invalid.ldap"; + + // Act & Assert + assertThatThrownBy(() -> adminValidator.validate(invalidLdap)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreException = (CoreException) ex; + assert coreException.getErrorType() == ErrorType.FORBIDDEN; + }); + } + + @Test + @DisplayName("null 값이면 FORBIDDEN 예외가 발생한다") + void fail_withNullLdap() { + // Arrange + String nullLdap = null; + + // Act & Assert + assertThatThrownBy(() -> adminValidator.validate(nullLdap)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreException = (CoreException) ex; + assert coreException.getErrorType() == ErrorType.FORBIDDEN; + }); + } + + @Test + @DisplayName("빈 문자열이면 FORBIDDEN 예외가 발생한다") + void fail_withEmptyLdap() { + // Arrange + String emptyLdap = ""; + + // Act & Assert + assertThatThrownBy(() -> adminValidator.validate(emptyLdap)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreException = (CoreException) ex; + assert coreException.getErrorType() == ErrorType.FORBIDDEN; + }); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/resources/docker-java.properties b/apps/commerce-api/src/test/resources/docker-java.properties new file mode 100644 index 000000000..e1af86b41 --- /dev/null +++ b/apps/commerce-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/claude/skills/requirements-analysis/SKILL.md b/claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..5abb83a0b --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,593 @@ +# 요구사항 분석 + +## 1. 회원 (Member) + +### 1.1 회원가입 + +| 항목 | 내용 | +|------|------| +| **Actor** | 비회원 | +| **목적** | 새로운 계정을 생성하여 서비스를 이용한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| MBR-001 | loginId는 중복될 수 없다 | 409 Conflict | +| MBR-002 | email은 중복될 수 없다 | 409 Conflict | +| MBR-003 | password는 영문 대/소문자, 숫자, 특수문자만 허용 | 400 Bad Request | +| MBR-004 | password에 생년월일(YYYYMMDD 형식) 포함 불가 | 400 Bad Request | +| MBR-005 | 모든 필수 항목은 빈 값일 수 없다 | 400 Bad Request | + +--- + +### 1.2 내 정보 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 자신의 회원 정보를 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| MBR-010 | 인증되지 않은 사용자는 조회 불가 | 401 Unauthorized | +| MBR-011 | 이름의 마지막 글자는 `*`로 마스킹 | - | + +--- + +### 1.3 비밀번호 변경 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 기존 비밀번호를 새 비밀번호로 변경한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| MBR-020 | 인증되지 않은 사용자는 변경 불가 | 401 Unauthorized | +| MBR-021 | 현재 비밀번호가 일치해야 한다 | 400 Bad Request | +| MBR-022 | 새 비밀번호는 MBR-003, MBR-004 규칙 적용 | 400 Bad Request | +| MBR-023 | 현재 비밀번호와 동일한 비밀번호로 변경 불가 | 400 Bad Request | +| MBR-024 | 모든 필수 항목은 빈 값일 수 없다 | 400 Bad Request | + +--- + +## 2. 배송지 (Address) + +### 2.1 배송지 목록 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 등록된 배송지 목록을 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADR-001 | 로그인 필수 | 401 Unauthorized | +| ADR-002 | 본인의 배송지만 조회 가능 | - | + +--- + +### 2.2 배송지 등록 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 새로운 배송지를 등록한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADR-010 | 로그인 필수 | 401 Unauthorized | +| ADR-011 | 필수 항목(recipientName, phone, address) 누락 불가 | 400 Bad Request | +| ADR-012 | 첫 배송지 등록 시 자동으로 기본 배송지 설정 | - | +| ADR-013 | 회원당 최대 5개까지만 등록 가능 | 400 Bad Request | + +--- + +### 2.3 배송지 수정 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 기존 배송지 정보를 수정한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADR-020 | 로그인 필수 | 401 Unauthorized | +| ADR-021 | 본인의 배송지만 수정 가능 | 403 Forbidden | +| ADR-022 | 존재하지 않는 배송지는 수정 불가 | 404 Not Found | +| ADR-023 | 필수 항목을 빈 값으로 수정 불가 | 400 Bad Request | + +--- + +### 2.4 배송지 삭제 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 배송지를 삭제한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADR-030 | 로그인 필수 | 401 Unauthorized | +| ADR-031 | 본인의 배송지만 삭제 가능 | 403 Forbidden | +| ADR-032 | 존재하지 않는 배송지는 삭제 불가 | 404 Not Found | +| ADR-033 | Hard Delete 적용 (물리적 삭제) | - | +| ADR-034 | 삭제 후 남은 배송지가 1개이면 자동으로 기본 배송지 설정 | - | + +--- + +### 2.5 기본 배송지 설정 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 특정 배송지를 기본 배송지로 설정한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADR-040 | 로그인 필수 | 401 Unauthorized | +| ADR-041 | 본인의 배송지만 설정 가능 | 403 Forbidden | +| ADR-042 | 존재하지 않는 배송지는 설정 불가 | 404 Not Found | +| ADR-043 | 기존 기본 배송지는 자동으로 해제 | - | + +--- + +## 3. 카테고리 (Category) + +### 3.1 카테고리 목록 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 모든 사용자 | +| **목적** | 상품 분류를 위한 카테고리 목록을 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| CAT-001 | 삭제되지 않은 카테고리만 조회 | - | +| CAT-002 | 계층 구조(parent-child)로 반환 | - | + +--- + +### 3.2 카테고리 삭제 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 카테고리를 삭제한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| CAT-010 | 관리자 권한 필수 | 403 Forbidden | +| CAT-011 | 존재하지 않는 카테고리는 삭제 불가 | 404 Not Found | +| CAT-012 | 하위 카테고리도 함께 Soft Delete 처리 | - | +| CAT-013 | 해당 카테고리 및 하위 카테고리에 속한 상품도 함께 Soft Delete 처리 | - | + +--- + +## 4. 브랜드 (Brand) - 사용자 + +### 4.1 브랜드 정보 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 모든 사용자 | +| **목적** | 특정 브랜드의 상세 정보를 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| BRD-001 | 존재하지 않는 브랜드는 조회 불가 | 404 Not Found | +| BRD-002 | 삭제된 브랜드는 조회 불가 | 404 Not Found | + +--- + +## 5. 상품 (Product) - 사용자 + +### 5.1 상품 목록 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 모든 사용자 | +| **목적** | 조건에 맞는 상품 목록을 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| PRD-001 | 존재하지 않는 카테고리의 상품 목록 조회 불가 | 404 Not Found | +| PRD-002 | 삭제된 상품은 목록에서 제외 | - | +| PRD-003 | 정렬: 최신순(기본), 가격순, 좋아요순 | - | +| PRD-004 | 페이징: 20(기본), 30, 50 | - | + +--- + +### 5.2 상품 상세 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 모든 사용자 | +| **목적** | 특정 상품의 상세 정보를 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| PRD-010 | 존재하지 않는 상품은 조회 불가 | 404 Not Found | +| PRD-011 | 삭제된 상품은 조회 불가 | 404 Not Found | + +--- + +## 6. 브랜드 & 상품 관리 (Admin) + +### 6.1 브랜드 목록 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 등록된 브랜드 목록을 관리한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-001 | 관리자 권한 필수 | 403 Forbidden | +| ADM-002 | 삭제된 브랜드는 목록에서 제외 | - | +| ADM-003 | 페이징: 20(기본), 30, 50 | - | + +--- + +### 6.2 브랜드 상세 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 브랜드의 상세 정보와 소속 상품을 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-010 | 관리자 권한 필수 | 403 Forbidden | +| ADM-011 | 존재하지 않는 브랜드는 조회 불가 | 404 Not Found | + +--- + +### 6.3 브랜드 등록 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 새로운 브랜드를 등록한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-020 | 관리자 권한 필수 | 403 Forbidden | +| ADM-021 | 필수 항목(name, description, logoImageUrl) 누락 불가 | 400 Bad Request | + +--- + +### 6.4 브랜드 수정 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 기존 브랜드 정보를 수정한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-030 | 관리자 권한 필수 | 403 Forbidden | +| ADM-031 | 존재하지 않는 브랜드는 수정 불가 | 404 Not Found | +| ADM-032 | 필수 항목을 빈 값으로 수정 불가 | 400 Bad Request | + +--- + +### 6.5 브랜드 삭제 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 브랜드를 삭제한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-040 | 관리자 권한 필수 | 403 Forbidden | +| ADM-041 | 존재하지 않는 브랜드는 삭제 불가 | 404 Not Found | +| ADM-042 | 소속 상품도 함께 삭제 처리 | - | +| ADM-043 | Soft Delete 적용 (deleted_at 설정) | - | + +--- + +### 6.6 상품 목록 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 등록된 상품 목록을 관리한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-050 | 관리자 권한 필수 | 403 Forbidden | +| ADM-051 | 모든 상태의 상품 조회 가능 (삭제 포함) | - | + +--- + +### 6.7 상품 상세 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 상품의 모든 관리 정보를 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-060 | 관리자 권한 필수 | 403 Forbidden | +| ADM-061 | 존재하지 않는 상품은 조회 불가 | 404 Not Found | +| ADM-062 | 품절/판매중지 상품도 조회 가능 | - | + +--- + +### 6.8 상품 등록 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 새로운 상품을 등록한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-070 | 관리자 권한 필수 | 403 Forbidden | +| ADM-071 | 존재하지 않는 브랜드/카테고리 지정 불가 | 404 Not Found | +| ADM-072 | 필수 항목(name, brandId, categoryId, basePrice, images) 누락 불가 | 400 Bad Request | +| ADM-073 | basePrice는 0 이상이어야 함 | 400 Bad Request | +| ADM-074 | discountType이 `RATE`인 경우 discount는 0~100 사이 | 400 Bad Request | +| ADM-075 | product_code는 등록일 기반으로 자동 생성 ({YYYYMMDD}-{5자리 순번}, 예: 20240101-00001) | - | +| ADM-076 | discountType이 `PRICE`인 경우 discount는 basePrice 이하이어야 함 | 400 Bad Request | + +--- + +### 6.9 상품 수정 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 기존 상품 정보를 수정한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-080 | 관리자 권한 필수 | 403 Forbidden | +| ADM-081 | 존재하지 않는 상품은 수정 불가 | 404 Not Found | +| ADM-082 | 필수 항목을 빈 값으로 수정 불가 | 400 Bad Request | +| ADM-083 | **브랜드는 수정 불가** | 400 Bad Request | +| ADM-084 | basePrice는 0 이상이어야 함 | 400 Bad Request | +| ADM-085 | discountType이 `RATE`인 경우 discount는 0~100 사이 | 400 Bad Request | +| ADM-086 | discountType이 `PRICE`인 경우 discount는 basePrice 이하이어야 함 | 400 Bad Request | + +--- + +### 6.10 상품 삭제 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 상품을 삭제한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ADM-090 | 관리자 권한 필수 | 403 Forbidden | +| ADM-091 | 존재하지 않는 상품은 삭제 불가 | 404 Not Found | +| ADM-092 | Soft Delete 적용 (deleted_at 설정) | - | + +--- + +## 7. 좋아요 (Like) + +### 7.1 상품 좋아요 토글 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 관심 상품을 좋아요 등록/취소한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| LIK-001 | 로그인 필수 | 401 Unauthorized | +| LIK-002 | 존재하지 않거나 삭제된 상품은 좋아요 불가 | 404 Not Found | +| LIK-003 | 이미 좋아요한 상품은 좋아요 취소 | - | + +--- + +### 7.2 브랜드 좋아요 토글 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 관심 브랜드를 좋아요 등록/취소한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| LIK-010 | 로그인 필수 | 401 Unauthorized | +| LIK-011 | 존재하지 않거나 삭제된 브랜드는 좋아요 불가 | 404 Not Found | +| LIK-012 | 이미 좋아요한 브랜드는 좋아요 취소 | - | + +--- + +## 8. 주문 (Order) - 사용자 + +### 8.1 주문 요청 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 상품을 주문한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ORD-001 | 로그인 필수 | 401 Unauthorized | +| ORD-002 | 판매중이 아니거나 삭제된 상품은 주문 불가 | 400 Bad Request | +| ORD-003 | 주문 수량 > 옵션 재고 수량인 경우 주문 불가 | 400 Bad Request | +| ORD-004 | 존재하지 않는 배송지(addressId)로 주문 불가 | 404 Not Found | +| ORD-005 | 주문 시 옵션 재고 차감 (product_options.stock_quantity 감소) | - | + +--- + +### 8.2 내 주문 목록 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 자신의 주문 내역을 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ORD-010 | 로그인 필수 | 401 Unauthorized | +| ORD-011 | 본인의 주문만 조회 가능 | - | +| ORD-012 | 조회 기간 필터: 3개월(기본), 6개월, 1년, 전체 | - | + +--- + +### 8.3 주문 상세 조회 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 특정 주문의 상세 정보를 확인한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ORD-020 | 로그인 필수 | 401 Unauthorized | +| ORD-021 | 본인의 주문이 아니면 조회 불가 | 403 Forbidden | +| ORD-022 | 존재하지 않는 주문은 조회 불가 | 404 Not Found | + +--- + +### 8.4 주문 취소 + +| 항목 | 내용 | +|------|------| +| **Actor** | 로그인 사용자 | +| **목적** | 주문을 취소하고 재고를 복구한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| ORD-030 | 로그인 필수 | 401 Unauthorized | +| ORD-031 | 본인의 주문이 아니면 취소 불가 | 403 Forbidden | +| ORD-032 | 존재하지 않는 주문은 취소 불가 | 404 Not Found | +| ORD-033 | PENDING, PAID 상태에서만 취소 가능 | 400 Bad Request | +| ORD-034 | 취소 시 주문 상품의 옵션 재고 복구 (stock_quantity 증가) | - | +| ORD-035 | 취소 완료 시 주문 상태를 CANCELLED로 변경 | - | +| ORD-036 | 취소 시 주문 상품(order_products)의 상태도 CANCELLED로 변경 | - | + +--- + +## 9. 주문 관리 (Order Admin) + +### 9.1 주문 목록 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 전체 주문 현황을 관리한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| OAD-001 | 관리자 권한 필수 | 403 Forbidden | +| OAD-002 | 상태, 기간, 키워드 필터 지원 | - | + +--- + +### 9.2 주문 상세 조회 (Admin) + +| 항목 | 내용 | +|------|------| +| **Actor** | 관리자 | +| **목적** | 주문의 모든 정보를 확인하고 관리한다 | + +**비즈니스 규칙** + +| 규칙 ID | 설명 | 위반 시 | +|---------|------|---------| +| OAD-010 | 관리자 권한 필수 | 403 Forbidden | +| OAD-011 | 존재하지 않는 주문은 조회 불가 | 404 Not Found | +| OAD-012 | 상태 변경 및 취소/환불 처리 가능 | - | +| OAD-013 | 주문 취소(CANCELLED) 시 주문 상품의 옵션 재고 복구 (stock_quantity 증가) | - | + +--- + +## 부록 + +### A. 주문 상태 + +| 상태 | 코드 | 설명 | +|------|------|------| +| 결제대기 | `PENDING` | 주문 생성, 결제 전 | +| 결제완료 | `PAID` | 결제 완료 | +| 배송준비 | `PREPARING` | 상품 준비 중 | +| 배송중 | `SHIPPING` | 배송 시작 | +| 배송완료 | `DELIVERED` | 배송 완료 | +| 주문취소 | `CANCELLED` | 주문 취소 | +| 반품/환불 | `RETURNED` | 반품 및 환불 처리 | + +### B. 상품 상태 + +| 상태 | 코드 | 설명 | +|------|------|------| +| 판매중 | `SALE` | 정상 판매 | +| 판매중지 | `STOP` | 판매 중지 | +| 품절 | `SOLDOUT` | 재고 소진 | + +### C. 할인 계산 + +| 할인 유형 | 계산 방식 | +|----------|----------| +| `PRICE` (금액) | discountedPrice = basePrice - discount | +| `RATE` (비율) | discountedPrice = basePrice × (1 - discount/100) | \ No newline at end of file diff --git a/docs/design/02-sequence-diagram.md b/docs/design/02-sequence-diagram.md new file mode 100644 index 000000000..721851022 --- /dev/null +++ b/docs/design/02-sequence-diagram.md @@ -0,0 +1,915 @@ +# 시퀀스 다이어그램 + +## 브랜드 & 상품 + +### [브랜드 정보 조회] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade + participant BrandService as 브랜드 서비스 + participant ProductService as 상품 서비스 + participant Repository + + Client->>Controller: GET /api/v1/brands/{brandId}?page=&size= + Controller->>Facade: getBrandInfo(brandId, pageable) + Facade->>BrandService: getActiveBrand(brandId) + BrandService->>Repository: findById(brandId) + + alt 브랜드 미존재 + Repository-->>BrandService: Empty + BrandService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Repository-->>BrandService: Brand + BrandService->>BrandService: 삭제 상태 검증 + + alt 삭제된 브랜드 + BrandService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + BrandService-->>Facade: Brand + Facade->>ProductService: getProductsByBrandId(brandId, pageable) + ProductService->>Repository: findByBrandId(brandId, pageable) + Repository-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: BrandInfo + Controller-->>Client: 200 OK +``` + +### [상품 목록 조회] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade + participant ProductService as 상품 서비스 + participant CategoryService as 카테고리 서비스 + participant Repository + + Client->>Controller: GET /api/v1/products?categoryId=&keyword=&sort=&page=&size= + Controller->>Facade: getProducts(categoryId, keyword, sort, pageable) + + opt 카테고리 필터 존재 + Facade->>CategoryService: validateCategory(categoryId) + CategoryService->>Repository: existsById(categoryId) + + alt 카테고리 미존재 + Repository-->>CategoryService: false + CategoryService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + end + + Facade->>ProductService: getProducts(categoryId, keyword, sort, pageable) + ProductService->>Repository: findProducts(조건) + Note over Repository: 삭제된 상품 제외 + Repository-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: Page + Controller-->>Client: 200 OK +``` + +### [상품 정보 조회] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade + participant ProductService as 상품 서비스 + participant BrandService as 브랜드 서비스 + participant Repository + + Client->>Controller: GET /api/v1/products/{productId} + Controller->>Facade: getProduct(productId) + Facade->>ProductService: getActiveProduct(productId) + ProductService->>Repository: findByIdWithOptionsAndImages(productId) + Note over Repository: Product + Options + Images Fetch Join + + alt 상품 미존재 + Repository-->>ProductService: Empty + ProductService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Repository-->>ProductService: Product (with Options, Images) + ProductService->>ProductService: 삭제 상태 검증 + + alt 삭제된 상품 + ProductService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + ProductService-->>Facade: Product + Facade->>BrandService: getActiveBrand(brandId) + BrandService->>Repository: findById(brandId) + Repository-->>BrandService: Brand + BrandService-->>Facade: Brand + Note over Facade: Product 애그리거트에서 Options, Images 직접 조회 + Facade-->>Controller: ProductDetailInfo (with Options, Images, Brand) + Controller-->>Client: 200 OK +``` + +--- + +## 브랜드 & 상품 ADMIN + +### [등록된 브랜드 목록 조회] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant Service as 브랜드 서비스 + participant Repository + + Admin->>Controller: GET /api/v1/admin/brands?page=&size= + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Controller->>Service: getBrands(pageable) + Service->>Repository: findAll(pageable) + Repository-->>Service: Page + Service-->>Controller: Page + Controller-->>Admin: 200 OK +``` + +### [브랜드 상세 조회 (ADMIN)] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant Facade + participant BrandService as 브랜드 서비스 + participant ProductService as 상품 서비스 + participant Repository + + Admin->>Controller: GET /api/v1/admin/brands/{brandId}?page=&size= + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Controller->>Facade: getBrandDetail(brandId, pageable) + Facade->>BrandService: getBrand(brandId) + BrandService->>Repository: findById(brandId) + + alt 브랜드 미존재 + Repository-->>BrandService: Empty + BrandService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>BrandService: Brand + BrandService-->>Facade: Brand + Facade->>ProductService: getProductsByBrandId(brandId, pageable) + ProductService->>Repository: findByBrandId(brandId, pageable) + Repository-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: BrandDetailInfo + Controller-->>Admin: 200 OK +``` + +### [브랜드 등록] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant BrandService as 브랜드 서비스 + participant Repository + + Admin->>Controller: POST /api/v1/admin/brands + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Note over Controller: 입력값 검증 (Bean Validation) + + alt 필수값 누락 + Controller-->>Admin: 400 Bad Request + end + + Controller->>BrandService: createBrand(name, description, logoImageUrl) + + BrandService->>Repository: save(brand) + Repository-->>BrandService: Brand + BrandService-->>Controller: Brand + Controller-->>Admin: 201 Created +``` + +### [브랜드 정보 수정] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant BrandService as 브랜드 서비스 + participant Repository + + Admin->>Controller: PUT /api/v1/admin/brands/{brandId} + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Note over Controller: 입력값 검증 + + alt 필수값을 빈 값으로 수정 시도 + Controller-->>Admin: 400 Bad Request + end + + Controller->>BrandService: updateBrand(brandId, name, description, logoImageUrl) + BrandService->>Repository: findById(brandId) + + alt 브랜드 미존재 + Repository-->>BrandService: Empty + BrandService-->>Controller: NOT_FOUND Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>BrandService: Brand + BrandService->>BrandService: Brand 정보 수정 + BrandService->>Repository: save(brand) + Repository-->>BrandService: Brand + BrandService-->>Controller: Brand + Controller-->>Admin: 200 OK +``` + +### [브랜드 삭제] + +```mermaid +sequenceDiagram + autonumber + participant Admin + participant Controller + participant Facade + participant BrandService as 브랜드 서비스 + participant ProductService as 상품 서비스 + participant Repository + + Admin->>Controller: DELETE /api/v1/admin/brands/{brandId} + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Controller->>Facade: deleteBrand(brandId) + Facade->>BrandService: validateBrand(brandId) + BrandService->>Repository: findById(brandId) + + alt 브랜드 미존재 + Repository-->>BrandService: Empty + BrandService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>BrandService: Brand + BrandService-->>Facade: Brand + + Facade->>ProductService: deleteProductsByBrandId(brandId) + ProductService->>Repository: findByBrandId(brandId) + Repository-->>ProductService: List + + loop 브랜드 상품들 + ProductService->>ProductService: Product 삭제 상태로 변경 + end + + ProductService->>Repository: saveAll(List) + Repository-->>ProductService: List + ProductService-->>Facade: 완료 + + Facade->>BrandService: deleteBrand(brandId) + BrandService->>BrandService: Brand 삭제 상태로 변경 (Soft Delete) + BrandService->>Repository: save(Brand) + Repository-->>BrandService: Brand + BrandService-->>Facade: 완료 + Facade-->>Controller: 완료 + Controller-->>Admin: 200 OK +``` + +### [상품 상세 조회 (ADMIN)] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant Facade + participant ProductService as 상품 서비스 + participant Repository + + Admin->>Controller: GET /api/v1/admin/products/{productId} + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Controller->>Facade: getProductDetail(productId) + Facade->>ProductService: getProduct(productId) + ProductService->>Repository: findByIdWithOptionsAndImages(productId) + Note over Repository: Product + Options + Images Fetch Join + + alt 상품 미존재 + Repository-->>ProductService: Empty + ProductService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>ProductService: Product (with Options, Images) + Note over ProductService: 품절/판매중지 상품도 조회 가능 + ProductService-->>Facade: Product + Note over Facade: Product 애그리거트에서 Options, Images 직접 조회 + Facade-->>Controller: ProductAdminDetailInfo + Note over Controller: 재고, 상태, 등록/수정일시 포함 + Controller-->>Admin: 200 OK +``` + +### [상품 등록] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant Facade + participant ProductService as 상품 서비스 + participant BrandService as 브랜드 서비스 + participant CategoryService as 카테고리 서비스 + participant Repository + + Admin->>Controller: POST /api/v1/admin/products + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Note over Controller: 입력값 검증 + + alt 필수값 누락 또는 유효성 위반 + Controller-->>Admin: 400 Bad Request + end + + Controller->>Facade: createProduct(brandId, categoryId, name, options, ...) + Facade->>BrandService: validateBrand(brandId) + + alt 브랜드 미존재 또는 삭제 상태 + BrandService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + BrandService-->>Facade: Brand + + Facade->>CategoryService: validateCategory(categoryId) + + + alt 카테고리 미존재 또는 삭제 상태 + CategoryService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + CategoryService-->>Facade: Category + + Facade->>ProductService: createProduct(brandId, categoryId, name...) + ProductService->>ProductService: Product 생성 + ProductService->>Repository: save(Product) + Repository-->>ProductService: Product + ProductService-->>Facade: Product + + Facade-->>Controller: ProductInfo + Controller-->>Admin: 201 Created +``` + +### [상품 정보 수정] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant Facade + participant ProductService as 상품 서비스 + participant CategoryService as 카테고리 서비스 + participant Repository + + Admin->>Controller: PUT /api/v1/admin/products/{productId} + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Note over Controller: 입력값 검증 + + alt 필수값을 빈 값으로 수정 또는 유효성 위반 + Controller-->>Admin: 400 Bad Request + end + + Controller->>Facade: updateProduct(productId, categoryId, name, options, ...) + + Facade->>CategoryService: validateCategory(categoryId) + + alt 카테고리 미존재 + CategoryService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + CategoryService-->>Facade: Category + + Facade->>ProductService: updateProduct(productId, categoryId, name, ...) + ProductService->>Repository: findById(productId) + + alt 상품 미존재 + Repository-->>ProductService: Empty + ProductService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>ProductService: Product + Note over ProductService: 브랜드는 수정 불가 + ProductService->>ProductService: Product 정보 수정 + ProductService->>Repository: save(product) + Repository-->>ProductService: Product + ProductService-->>Facade: Product + + Facade-->>Controller: ProductInfo + Controller-->>Admin: 200 OK +``` + +### [상품 삭제] + +```mermaid +sequenceDiagram + autonumber + participant Admin as 관리자 + participant Controller + participant ProductService as 상품 서비스 + participant Repository + + Admin->>Controller: DELETE /api/v1/admin/products/{productId} + Note over Controller: X-Loopers-Ldap: loopers.admin 헤더 검증 + + alt 관리자 인증 실패 + Controller-->>Admin: 403 Forbidden + end + + Controller->>ProductService: deleteProduct(productId) + ProductService->>Repository: findById(productId) + + alt 상품 미존재 + Repository-->>ProductService: Empty + ProductService-->>Controller: NOT_FOUND Exception + Controller-->>Admin: 404 Not Found + end + + Repository-->>ProductService: Product + ProductService->>ProductService: Product 삭제 상태로 변경 (Soft Delete) + ProductService->>Repository: save(product) + Repository-->>ProductService: Product + ProductService-->>Controller: 완료 + Controller-->>Admin: 200 OK +``` + +--- + +## 좋아요 + +### [상품 좋아요 등록/취소] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as LikeFacade + participant MemberService as 회원 서비스 + participant Validator as ProductLikeTargetValidator + participant LikeService as 좋아요 서비스 + participant Repository + + Client->>Controller: POST /api/v1/products/{productId}/likes + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: toggleLike(loginId, password, productId, PRODUCT) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>Facade: validators에서 supportedType == PRODUCT 조회 + Facade->>Validator: validate(productId) + + alt 상품 미존재 또는 삭제됨 + Validator-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Validator-->>Facade: 검증 통과 + + Facade->>LikeService: toggleLike(memberId, productId, PRODUCT) + LikeService->>Repository: findByMemberIdAndTargetIdAndTargetType(memberId, productId, PRODUCT) + + alt 이미 좋아요한 경우 (취소) + Repository-->>LikeService: Like + LikeService->>Repository: delete(like) + LikeService-->>Facade: LikeResult(cancelled) + Facade-->>Controller: LikeInfo(cancelled) + Controller-->>Client: 200 OK (좋아요 취소) + else 좋아요하지 않은 경우 (등록) + Repository-->>LikeService: Empty + LikeService->>LikeService: Like 생성 (targetType=PRODUCT) + LikeService->>Repository: save(like) + LikeService-->>Facade: LikeResult(liked) + Facade-->>Controller: LikeInfo(liked) + Controller-->>Client: 200 OK (좋아요 등록) + end +``` + +### [브랜드 좋아요 등록/취소] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as LikeFacade + participant MemberService as 회원 서비스 + participant Validator as BrandLikeTargetValidator + participant LikeService as 좋아요 서비스 + participant Repository + + Client->>Controller: POST /api/v1/brands/{brandId}/likes + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: toggleLike(loginId, password, brandId, BRAND) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>Facade: validators에서 supportedType == BRAND 조회 + Facade->>Validator: validate(brandId) + + alt 브랜드 미존재 또는 삭제됨 + Validator-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Validator-->>Facade: 검증 통과 + + Facade->>LikeService: toggleLike(memberId, brandId, BRAND) + LikeService->>Repository: findByMemberIdAndTargetIdAndTargetType(memberId, brandId, BRAND) + + alt 이미 좋아요한 경우 (취소) + Repository-->>LikeService: Like + LikeService->>Repository: delete(like) + LikeService-->>Facade: LikeResult(cancelled) + Facade-->>Controller: LikeInfo(cancelled) + Controller-->>Client: 200 OK (좋아요 취소) + else 좋아요하지 않은 경우 (등록) + Repository-->>LikeService: Empty + LikeService->>LikeService: Like 생성 (targetType=BRAND) + LikeService->>Repository: save(like) + LikeService-->>Facade: LikeResult(liked) + Facade-->>Controller: LikeInfo(liked) + Controller-->>Client: 200 OK (좋아요 등록) + end +``` + +--- + +## 주문 + +### [주문 요청] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as OrderFacade + participant MemberService as 회원 서비스 + participant OrderService as 주문 서비스 + participant ProductService as 상품 서비스 + participant MemberAddressService as 배송지 서비스 + participant Repository + + Client->>Controller: POST /api/v1/orders + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Note over Controller: 입력값 검증 + + alt 배송지 정보 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: createOrder(loginId, password, orderItems, addressId, shippingMemo) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>MemberAddressService: getAddress(memberId, addressId) + MemberAddressService->>Repository: findByMemberIdAndId(memberId, addressId) + + alt 배송지 미존재 + Repository-->>MemberAddressService: Empty + MemberAddressService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Repository-->>MemberAddressService: MemberAddress + MemberAddressService-->>Facade: MemberAddress + + Facade->>ProductService: validateProducts(List) + ProductService->>Repository: findAllByIdInWithOptions(List) + + alt [일부, 전체] 상품 미존재 또는 삭제 + Repository-->>ProductService: List 또는 Empty + ProductService-->>Facade: BAD_REQUEST Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 400 Bad Request + end + + Repository-->>ProductService: List + + + loop 주문 상품별 + ProductService->>ProductService: 재고 검증 + + alt 재고 부족 + ProductService-->>Facade: BAD_REQUEST Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 400 Bad Request + end + end + + loop 주문 상품별 + ProductService->>ProductService: 재고 차감 (decreaseStock) + end + + ProductService-->>Facade: List + + Facade->>OrderService: createOrder(memberId, List, shippingInfo) + + + OrderService->>OrderService: 주문 금액 계산 + OrderService->>OrderService: 주문 생성 + OrderService->>Repository: save(Order) + Repository-->>OrderService: Order + + OrderService-->>Facade: Order + Facade-->>Controller: OrderInfo + Controller-->>Client: 201 Created +``` + +### [유저의 주문 목록 조회] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as OrderFacade + participant MemberService as 회원 서비스 + participant OrderService as 주문 서비스 + participant Repository + + Client->>Controller: GET /api/v1/orders?period=&page=&size= + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + Note over Controller: period: 3M(기본), 6M, 1Y, ALL + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: getMyOrders(loginId, password, period, pageable) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>OrderService: getOrdersByMemberId(memberId, period, pageable) + OrderService->>Repository: findByMemberId(memberId, period, pageable) + Note over Repository: 기간 필터 적용, 최신 주문순 정렬 + Repository-->>OrderService: Page + OrderService-->>Facade: Page + Facade-->>Controller: Page + Note over Controller: 주문번호, 일자, 대표상품명, 총금액, 상태 + Controller-->>Client: 200 OK +``` + +### [단일 주문 상세 조회] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as OrderFacade + participant MemberService as 회원 서비스 + participant OrderService as 주문 서비스 + participant Repository + + Client->>Controller: GET /api/v1/orders/{orderId} + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: getOrderDetail(loginId, password, orderId) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>OrderService: getOrder(orderId) + OrderService->>Repository: findById(orderId) + + alt 주문 미존재 + Repository-->>OrderService: Empty + OrderService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Repository-->>OrderService: Order + OrderService-->>Facade: Order + + Facade->>OrderService: validateOrderOwner(orderId, memberId) + + alt 본인 주문 아님 + OrderService-->>Facade: FORBIDDEN Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 403 Forbidden + end + + Facade->>OrderService: getOrderProducts(orderId) + OrderService->>Repository: findByOrderId(orderId) + Repository-->>OrderService: List + OrderService-->>Facade: List + Facade-->>Controller: OrderDetailInfo + Note over Controller: 주문정보, 상품정보, 배송지, 결제내역 + Controller-->>Client: 200 OK +``` + +### [주문 취소] + +```mermaid +sequenceDiagram + autonumber + participant Client as 사용자 + participant Controller + participant Facade as OrderFacade + participant MemberService as 회원 서비스 + participant OrderService as 주문 서비스 + participant ProductService as 상품 서비스 + participant Repository + + Client->>Controller: PATCH /api/v1/orders/{orderId}/cancel + Note over Controller: X-Loopers-LoginId, X-Loopers-LoginPw 헤더 + + alt 인증 헤더 누락 + Controller-->>Client: 400 Bad Request + end + + Controller->>Facade: cancelOrder(loginId, password, orderId) + Facade->>MemberService: authenticate(loginId, password) + + alt 인증 실패 + MemberService-->>Facade: UNAUTHORIZED Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 401 Unauthorized + end + + MemberService-->>Facade: Member + + Facade->>OrderService: getOrder(orderId) + OrderService->>Repository: findById(orderId) + + alt 주문 미존재 + Repository-->>OrderService: Empty + OrderService-->>Facade: NOT_FOUND Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 404 Not Found + end + + Repository-->>OrderService: Order + OrderService-->>Facade: Order + + Facade->>OrderService: validateOrderOwner(orderId, memberId) + + alt 본인 주문 아님 + OrderService-->>Facade: FORBIDDEN Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 403 Forbidden + end + + Facade->>OrderService: getOrderProducts(orderId) + OrderService->>Repository: findByOrderId(orderId) + Repository-->>OrderService: List + OrderService-->>Facade: List + + Facade->>OrderService: cancelOrder(orderId) + OrderService->>OrderService: canCancel() 검증 + + alt PENDING/PAID 상태가 아님 + OrderService-->>Facade: BAD_REQUEST Exception + Facade-->>Controller: throw Exception + Controller-->>Client: 400 Bad Request + end + + OrderService->>OrderService: Order 상태 CANCELLED 변경 + + loop 주문 상품별 + OrderService->>OrderService: OrderProduct 상태 CANCELLED 변경 + end + + OrderService->>Repository: save(Order) + Repository-->>OrderService: Order + OrderService-->>Facade: Order + + loop 주문 상품별 재고 복구 + Facade->>ProductService: increaseStock(productOptionId, quantity) + end + + Facade-->>Controller: OrderInfo + Controller-->>Client: 200 OK +``` \ No newline at end of file diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..bc8ab58f0 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,1577 @@ +# 클래스 다이어그램 + +> 💡 **레이어 구조**: `Interfaces(Controller) → Application(Facade) → Domain(Service, Entity) ← Infrastructure(Repository)` + +--- + +## 전체 아키텍처 개요 + +```mermaid +classDiagram + direction TB + + class Controller { + <> + } + class Facade { + <> + } + class Service { + <> + } + class Repository { + <> + } + + Controller --> Facade : uses + Facade --> Service : uses + Service --> Repository : uses +``` + +--- + +## 유저 (Member) + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class MemberController { + -MemberFacade memberFacade + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + +updatePassword(loginId, password, UpdatePasswordRequest) ApiResponse~Void~ + } + + class SignUpRequest { + +String loginId + +String password + +String name + +String birthday + +String email + } + + class SignUpResponse { + +Long id + +String loginId + +String name + +String email + } + + class MyInfoResponse { + +String loginId + +String name + +String birthday + +String email + } + + class UpdatePasswordRequest { + +String currentPassword + +String newPassword + } + + %% Application Layer + class MemberFacade { + -MemberService memberService + +signUp(loginId, password, name, birthday, email) MemberInfo + +getMyInfo(loginId, password) MemberInfo + +updatePassword(loginId, password, currentPassword, newPassword) void + } + + class MemberInfo { + +Long id + +String loginId + +String name + +LocalDate birthday + +String email + +withMaskedName() MemberInfo + } + + %% Domain Layer + class Member { + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +encryptPassword(encodedPassword) void + +changePassword(newRawPassword, newEncodedPassword) void + -validateBirthday(birthday) void + -validatePasswordNotContainsBirthday(password, birthday) void + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +signUp(loginId, password, name, birthday, email) Member + +authenticate(loginId, password) Member + +updatePassword(loginId, currentPassword, newPassword) void + } + + %% Infrastructure Layer + class MemberRepository { + <> + +save(member) Member + +findByLoginId(loginId) Optional~Member~ + +existsByLoginId(loginId) boolean + +existsByEmail(email) boolean + +updatePassword(loginId, encodedPassword) void + } + + class MemberRepositoryImpl { + -MemberJpaRepository jpaRepository + } + + class MemberEntity { + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + -LocalDateTime createdAt + -LocalDateTime updatedAt + +toDomain() Member + +from(member)$ MemberEntity + } + + %% Relationships + MemberController --> MemberFacade + MemberController ..> SignUpRequest + MemberController ..> SignUpResponse + MemberController ..> MyInfoResponse + MemberController ..> UpdatePasswordRequest + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberService --> MemberRepository + MemberService --> Member + MemberRepositoryImpl ..|> MemberRepository + MemberRepositoryImpl --> MemberEntity +``` + +--- + +## 브랜드 (Brand) + +### 왜 필요한가? + +브랜드 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **책임 분리**: 일반 사용자 API와 Admin API의 경계가 명확한가? +- **의존 방향**: Controller → Service/Facade → Repository 단방향 의존이 지켜지는가? +- **Facade 사용 기준**: 복합 로직(Brand + Product)에만 Facade를 사용하고 있는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class BrandController { + -BrandFacade brandFacade + +getBrandInfo(brandId, pageable) ApiResponse~BrandInfoResponse~ + } + + class BrandAdminController { + -BrandFacade brandFacade + -BrandService brandService + +getBrands(pageable) ApiResponse~Page~ + +getBrandDetail(brandId, pageable) ApiResponse~BrandDetailResponse~ + +createBrand(CreateBrandRequest) ApiResponse~BrandResponse~ + +updateBrand(brandId, UpdateBrandRequest) ApiResponse~BrandResponse~ + +deleteBrand(brandId) ApiResponse~Void~ + } + + class CreateBrandRequest { + +String name + +String description + +String logoImageUrl + } + + class UpdateBrandRequest { + +String name + +String description + +String logoImageUrl + } + + class BrandInfoResponse { + +Long id + +String name + +String description + +String logoImageUrl + +Page~ProductInfo~ products + } + + class BrandResponse { + +Long id + +String name + +String description + +String logoImageUrl + +LocalDateTime createdAt + +LocalDateTime updatedAt + } + + class BrandDetailResponse { + +Long id + +String name + +String description + +String logoImageUrl + +LocalDateTime createdAt + +LocalDateTime updatedAt + +LocalDateTime deletedAt + +Page~ProductInfo~ products + } + + %% Application Layer + class BrandFacade { + -BrandService brandService + -ProductService productService + +getBrandInfo(brandId, pageable) BrandInfo + +getBrandDetail(brandId, pageable) BrandDetailInfo + +deleteBrand(brandId) void + } + + class BrandInfo { + +Long id + +String name + +String description + +String logoImageUrl + +Page~ProductInfo~ products + } + + class BrandDetailInfo { + +Long id + +String name + +String description + +String logoImageUrl + +LocalDateTime createdAt + +LocalDateTime updatedAt + +LocalDateTime deletedAt + +Page~ProductInfo~ products + } + + %% Domain Layer + class Brand { + -Long id + -String name + -String description + -String logoImageUrl + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +update(name, description, logoImageUrl) void + +delete() void + +isDeleted() boolean + } + + class BrandService { + -BrandRepository brandRepository + +getBrand(brandId) Brand + +getActiveBrand(brandId) Brand + +getBrands(pageable) Page~Brand~ + +createBrand(name, description, logoImageUrl) Brand + +updateBrand(brandId, name, description, logoImageUrl) Brand + +deleteBrand(brandId) void + +validateBrand(brandId) Brand + } + + %% Infrastructure Layer + class BrandRepository { + <> + +findById(brandId) Optional~Brand~ + +findAll(pageable) Page~Brand~ + +save(brand) Brand + +existsById(brandId) boolean + } + + class BrandRepositoryImpl { + -BrandJpaRepository jpaRepository + } + + class BrandEntity { + -Long id + -String name + -String description + -String logoImageUrl + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +toDomain() Brand + +from(brand)$ BrandEntity + } + + %% Relationships + BrandController --> BrandFacade + BrandAdminController --> BrandFacade + BrandAdminController --> BrandService + BrandFacade --> BrandService + BrandFacade --> ProductService + BrandFacade ..> BrandInfo + BrandService --> BrandRepository + BrandService --> Brand + BrandRepositoryImpl ..|> BrandRepository + BrandRepositoryImpl --> BrandEntity +``` + +### 핵심 포인트 + +1. **Facade 분기점**: `BrandAdminController`는 단순 CRUD(`getBrands`, `createBrand`, `updateBrand`)는 Service 직접 호출, 복합 로직(`getBrandDetail`, `deleteBrand`)은 Facade 경유 +2. **Domain ↔ Entity 분리**: `Brand`(도메인)와 `BrandEntity`(영속성)를 분리하여 도메인 로직이 JPA에 의존하지 않음 +3. **삭제 시 연쇄 처리**: `BrandFacade.deleteBrand()`는 `ProductService`를 호출하여 브랜드 소속 상품도 함께 삭제 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **브랜드 삭제 트랜잭션 비대화** | 상품이 많으면 삭제 시간 증가, 락 경합 | 비동기 삭제 또는 배치 처리 검토 | +| **Facade 사용 기준 모호** | 개발자마다 판단 기준 다를 수 있음 | 팀 내 명확한 기준 문서화 필요 | +| **순환 의존 가능성** | ProductService가 BrandService를 참조하면 순환 발생 | Facade에서만 조합, Service 간 직접 참조 금지 | + +--- + +## 카테고리 (Category) + +### 왜 필요한가? + +카테고리 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **계층 구조 설계**: parentId, path, depth를 통한 트리 구조가 적절한가? +- **Admin 전용 CRUD**: 관리자만 카테고리를 생성/수정/삭제할 수 있는가? +- **삭제 정책**: 하위 카테고리나 상품이 존재할 때 삭제 처리는 어떻게 되는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class CategoryController { + -CategoryService categoryService + +getCategories() ApiResponse~List~CategoryResponse~~ + } + + class CategoryAdminController { + -CategoryService categoryService + -CategoryFacade categoryFacade + +getCategories(pageable) ApiResponse~Page~ + +getCategoryDetail(categoryId) ApiResponse~CategoryDetailResponse~ + +createCategory(CreateCategoryRequest) ApiResponse~CategoryResponse~ + +updateCategory(categoryId, UpdateCategoryRequest) ApiResponse~CategoryResponse~ + +deleteCategory(categoryId) ApiResponse~Void~ + } + + class CreateCategoryRequest { + +Long parentId + +String name + } + + class UpdateCategoryRequest { + +String name + } + + class CategoryResponse { + +Long id + +Long parentId + +String name + +String path + +Integer depth + } + + class CategoryDetailResponse { + +Long id + +Long parentId + +String name + +String path + +Integer depth + +LocalDateTime createdAt + +LocalDateTime updatedAt + +LocalDateTime deletedAt + } + + %% Application Layer + class CategoryFacade { + -CategoryService categoryService + -ProductService productService + +deleteCategory(categoryId) void + } + + %% Domain Layer + class Category { + -Long id + -Long parentId + -String name + -String path + -Integer depth + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +update(name) void + +delete() void + +isDeleted() boolean + +hasParent() boolean + } + + class CategoryService { + -CategoryRepository categoryRepository + +getCategory(categoryId) Category + +getCategories() List~Category~ + +getCategories(pageable) Page~Category~ + +createCategory(parentId, name) Category + +updateCategory(categoryId, name) Category + +deleteCategory(categoryId) void + +validateCategory(categoryId) Category + +existsById(categoryId) boolean + +getChildCategories(categoryId) List~Category~ + } + + %% Infrastructure Layer + class CategoryRepository { + <> + +findById(categoryId) Optional~Category~ + +findAll(pageable) Page~Category~ + +findByParentId(parentId) List~Category~ + +existsById(categoryId) boolean + +save(category) Category + } + + class CategoryRepositoryImpl { + -CategoryJpaRepository jpaRepository + } + + class CategoryEntity { + -Long id + -Long parentId + -String name + -String path + -Integer depth + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +toDomain() Category + +from(category)$ CategoryEntity + } + + %% Relationships + CategoryController --> CategoryService : 목록 조회 + CategoryAdminController --> CategoryService : 단순 CRUD + CategoryAdminController --> CategoryFacade : 복합 로직 + CategoryFacade --> CategoryService + CategoryFacade --> ProductService + CategoryService --> CategoryRepository + CategoryService --> Category + CategoryRepositoryImpl ..|> CategoryRepository + CategoryRepositoryImpl --> CategoryEntity +``` + +### 핵심 포인트 + +1. **사용자/Admin 분리**: `CategoryController`는 삭제되지 않은 카테고리 목록 조회만 제공, CRUD는 `CategoryAdminController`에서 관리자 전용으로 처리 +2. **계층 구조**: `parentId`로 부모-자식 관계, `path`로 전체 경로, `depth`로 깊이 관리 +3. **삭제 시 Facade 경유**: 하위 카테고리/상품 존재 여부 검증을 위해 `CategoryFacade.deleteCategory()` 사용 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **연쇄 삭제 트랜잭션 비대화** | 하위 카테고리/상품이 많으면 삭제 시간 증가, 락 경합 | 비동기 삭제 또는 배치 처리 검토 | +| **path 갱신 복잡성** | 카테고리 이동 시 하위 모든 path 갱신 필요 | 현재는 카테고리 이동 미지원, 추후 고려 | + +> **정책 결정 완료**: 카테고리 삭제 시 하위 카테고리 및 소속 상품 모두 연쇄 Soft Delete 처리 (CAT-012, CAT-013) + +--- + +## 상품 (Product) + +### 왜 필요한가? + +상품 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **Facade 사용 기준**: 다른 도메인 서비스 호출이 필요한 경우에만 Facade를 사용하는가? +- **도메인 경계 유지**: ProductService가 다른 도메인의 Repository를 직접 참조하지 않는가? +- **조회 시 검증 정책**: 존재하지 않는 카테고리 필터 시 404 Not Found 반환 +- **애그리거트 패턴**: Product가 Options, Images를 애그리거트 루트로서 관리하는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class ProductController { + -ProductFacade productFacade + +getProducts(categoryId, keyword, sort, pageable) ApiResponse~Page~ + +getProduct(productId) ApiResponse~ProductDetailResponse~ + } + + class ProductAdminController { + -ProductService productService + -ProductFacade productFacade + +getProducts(filters, pageable) ApiResponse~Page~ + +getProductDetail(productId) ApiResponse~ProductAdminDetailResponse~ + +createProduct(CreateProductRequest) ApiResponse~ProductResponse~ + +updateProduct(productId, UpdateProductRequest) ApiResponse~ProductResponse~ + +deleteProduct(productId) ApiResponse~Void~ + } + + class ProductDetailResponse { + +Long id + +String name + +String productCode + +Long basePrice + +Long discountedPrice + +ProductStatus status + +BrandInfo brand + +Long likeCount + +List~ProductImageInfo~ images + +List~ProductOptionInfo~ options + } + + class ProductResponse { + +Long id + +String name + +String productCode + +Long basePrice + +Long discountedPrice + +ProductStatus status + +Long brandId + +Long categoryId + +LocalDateTime createdAt + +LocalDateTime updatedAt + } + + class ProductAdminDetailResponse { + +Long id + +String name + +String productCode + +Long brandId + +Long categoryId + +Long basePrice + +Long discountedPrice + +ProductStatus status + +Long discount + +DiscountType discountType + +List~ProductOptionInfo~ options + +List~ProductImageInfo~ images + +LocalDateTime createdAt + +LocalDateTime updatedAt + +LocalDateTime deletedAt + } + + class CreateProductRequest { + +String name + +Long brandId + +Long categoryId + +Long basePrice + +ProductStatus status + +Long discount + +DiscountType discountType + +List~CreateProductOptionRequest~ options + +List~CreateProductImageRequest~ images + } + + class UpdateProductRequest { + +String name + +Long categoryId + +Long basePrice + +ProductStatus status + +Long discount + +DiscountType discountType + +List~CreateProductOptionRequest~ options + +List~CreateProductImageRequest~ images + } + + %% Application Layer + class ProductFacade { + -ProductService productService + -BrandService brandService + -CategoryService categoryService + -AdminValidator adminValidator + +getProducts(categoryId, keyword, sort, pageable) Page~ProductInfo~ + +getProduct(productId) ProductDetailInfo + +getProductDetail(ldap, productId) ProductAdminDetailInfo + +createProduct(ldap, command) ProductAdminDetailInfo + +deleteProduct(ldap, productId) void + } + + class ProductInfo { + +Long id + +String name + +String productCode + +Long basePrice + +Long discountedPrice + +ProductStatus status + } + + class ProductDetailInfo { + +Long id + +String name + +String productCode + +Long basePrice + +Long discountedPrice + +ProductStatus status + +Long discount + +DiscountType discountType + +BrandInfo brand + +Long likeCount + +List~ProductOptionInfo~ options + +List~ProductImageInfo~ images + } + + class ProductAdminDetailInfo { + +Long id + +String name + +String productCode + +Long brandId + +Long categoryId + +Long basePrice + +Long discountedPrice + +ProductStatus status + +Long discount + +DiscountType discountType + +List~ProductOptionInfo~ options + +List~ProductImageInfo~ images + +LocalDateTime createdAt + +LocalDateTime updatedAt + +LocalDateTime deletedAt + } + + %% Domain Layer + class Product { + -Long id + -String name + -String productCode + -Long basePrice + -ProductStatus status + -Long brandId + -Long categoryId + -Long discount + -DiscountType discountType + -List~ProductOption~ options + -List~ProductImage~ images + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +calculateDiscountedPrice() Long + +isAvailable() boolean + +isDeleted() boolean + +delete() void + +addOption(option) void + +addImage(image) void + +getOption(optionId) ProductOption + +decreaseStock(optionId, quantity) void + +increaseStock(optionId, quantity) void + } + + class ProductStatus { + <> + SALE + STOP + SOLDOUT + } + + class DiscountType { + <> + PRICE + RATE + } + + class ProductService { + -ProductRepository productRepository + +getProduct(productId) Product + +getActiveProduct(productId) Product + +getProducts(categoryId, keyword, sort, pageable) Page~Product~ + +getProductsByBrandId(brandId, pageable) Page~Product~ + +createProduct(product) Product + +deleteProduct(productId) void + +deleteProductsByBrandId(brandId) void + +decreaseStock(productId, optionId, quantity) void + +increaseStock(productId, optionId, quantity) void + } + + %% Infrastructure Layer + class ProductRepository { + <> + +findById(productId) Optional~Product~ + +findByIdWithOptionsAndImages(productId) Optional~Product~ + +findByBrandId(brandId) List~Product~ + +findByBrandId(brandId, pageable) Page~Product~ + +findProducts(categoryId, keyword, sort, pageable) Page~Product~ + +save(product) Product + +saveAll(products) List~Product~ + } + + class ProductEntity { + -Long id + -String name + -String productCode + -Long basePrice + -ProductStatus status + -Long brandId + -Long categoryId + -Long discount + -DiscountType discountType + -Set~ProductOptionEntity~ options + -Set~ProductImageEntity~ images + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +toDomain() Product + +from(product)$ ProductEntity + +addOption(option) void + +addImage(image) void + } + + %% Relationships + ProductController --> ProductFacade + ProductAdminController --> ProductFacade + ProductFacade --> ProductService + ProductFacade --> BrandService + ProductFacade --> CategoryService + ProductService --> ProductRepository + ProductService --> Product + Product --> ProductStatus + Product --> DiscountType + Product "1" --> "*" ProductOption : aggregate + Product "1" --> "*" ProductImage : aggregate +``` + +### 핵심 포인트 + +1. **애그리거트 패턴 적용** + - Product가 애그리거트 루트로서 Options, Images를 직접 관리 + - ProductOptionService 제거 → Product 도메인 객체를 통해 옵션/이미지 접근 + - JPA `cascade = CascadeType.ALL, orphanRemoval = true`로 CUD 자동 처리 + +2. **Facade 사용 기준 명확화** + - `getProducts()` → **Facade** (카테고리 존재 검증 포함) + - `getProduct()` → **Facade** (Product 애그리거트 + Brand 조합) + - `getProductDetail()` → **Facade** (Admin 전용, Product 애그리거트 반환) + - `createProduct()` → **Facade** (Brand/Category 존재 검증) + - `deleteProduct()` → **Facade** (Admin 검증 + Soft Delete) + +3. **재고 관리**: `ProductService.decreaseStock(productId, optionId, quantity)` → `Product.decreaseStock(optionId, quantity)` → `ProductOption.decreaseStock(quantity)` 체인으로 처리 + +4. **Fetch Join**: `findByIdWithOptionsAndImages()`로 N+1 문제 방지 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **애그리거트 크기** | 옵션/이미지가 많으면 메모리 부담 | 상세 조회 시에만 Fetch Join, 목록 조회는 Product만 | +| **brandId 변경 불가 정책** | 요구사항에 명시되어 있으나 검증 로직 필요 | updateProduct에서 brandId 변경 시도 시 예외 | + +--- + +## 상품 옵션 (Product Option) + +### 왜 필요한가? + +상품 옵션 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **단순한 옵션 구조**: 옵션별로 추가 금액과 재고를 직접 관리하는가? +- **재고 관리 책임**: 옵션 단위 재고 관리가 명확한가? +- **애그리거트 소속**: ProductOption, ProductImage가 Product 애그리거트의 일부인가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Domain Layer - Option (Product Aggregate) + class ProductOption { + -Long id + -Long productId + -String optionValue + -String displayName + -Long extraPrice + -Integer stockQuantity + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +getDisplayValue() String + +isDeleted() boolean + +hasStock(quantity) boolean + +decreaseStock(quantity) void + +increaseStock(quantity) void + +delete() void + } + + %% Domain Layer - Image (Product Aggregate) + class ProductImage { + -Long id + -Long productId + -ImageType type + -String url + -String altText + -LocalDateTime createdAt + -LocalDateTime updatedAt + } + + class ImageType { + <> + MAIN + SUB + DETAIL + } + + %% Note: ProductOption과 ProductImage는 Product 애그리거트의 일부 + %% 별도의 Service/Repository 없이 Product를 통해 관리됨 + %% JPA Cascade로 CUD 처리 + + ProductImage --> ImageType +``` + +### 핵심 포인트 + +1. **애그리거트 패턴**: ProductOption과 ProductImage는 Product의 하위 엔티티로, Product를 통해서만 접근 +2. **JPA Cascade**: `cascade = CascadeType.ALL, orphanRemoval = true`로 CUD 자동 처리 +3. **재고 관리**: `Product.decreaseStock(optionId, quantity)`로 Product를 통해 재고 관리 +4. **Fetch Join**: `findByIdWithOptionsAndImages()`로 N+1 문제 방지 +5. **Service/Repository 제거**: ProductOptionService, ProductOptionRepository, ProductImageRepository 제거 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **재고 동시성 이슈** | 동시 주문 시 재고 차감 경합 | 비관적 락 또는 Redis 분산 락 적용 | +| **옵션 삭제 시 주문 데이터 정합성** | 삭제된 옵션을 참조하는 주문 존재 | Soft Delete + 주문에 스냅샷 데이터 저장 (현재 ERD에 반영됨) | +| **옵션 조합 미지원** | 색상+사이즈 조합별 재고 관리 불가 | 현재는 학습 프로젝트이므로 단일 옵션으로 충분 | + +--- + +## 좋아요 (Like) + +### 왜 필요한가? + +좋아요 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **토글 동작**: 좋아요 추가/취소가 단일 API로 처리되는가? +- **도메인 경계**: LikeService가 다른 도메인 Repository를 직접 참조하지 않는가? +- **다형성 지원**: 상품과 브랜드 좋아요가 동일한 도메인 로직으로 처리되는가? +- **대상 존재 검증**: 좋아요 대상의 존재 여부를 어디서, 어떻게 검증하는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class LikeController { + -LikeFacade likeFacade + +toggleProductLike(loginId, password, productId) ApiResponse~LikeResponse~ + +toggleBrandLike(loginId, password, brandId) ApiResponse~LikeResponse~ + } + + class LikeResponse { + +Boolean liked + +Long likeCount + } + + %% Application Layer + class LikeFacade { + -MemberService memberService + -LikeService likeService + -List~LikeTargetValidator~ validators + +toggleLike(loginId, password, targetId, targetType) LikeInfo + } + + class LikeInfo { + +Boolean liked + +Long likeCount + } + + class ProductLikeTargetValidator { + -ProductService productService + +supportedType() TargetType + +validate(targetId) void + } + + class BrandLikeTargetValidator { + -BrandService brandService + +supportedType() TargetType + +validate(targetId) void + } + + %% Domain Layer + class LikeTargetValidator { + <> + +supportedType() TargetType + +validate(targetId) void + } + + class TargetType { + <> + PRODUCT + BRAND + } + + class Like { + -Long id + -Long memberId + -Long targetId + -TargetType targetType + -LocalDateTime createdAt + } + + class LikeService { + -LikeRepository likeRepository + +toggleLike(memberId, targetId, targetType) LikeResult + +getLikeCount(targetId, targetType) Long + +isLiked(memberId, targetId, targetType) boolean + } + + class LikeResult { + +Boolean liked + +Long likeCount + } + + %% Infrastructure Layer + class LikeRepository { + <> + +findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType) Optional~Like~ + +save(like) Like + +delete(like) void + +countByTargetIdAndTargetType(targetId, targetType) Long + } + + class LikeRepositoryImpl { + -LikeJpaRepository jpaRepository + } + + class LikeEntity { + -Long id + -Long memberId + -Long targetId + -TargetType targetType + -LocalDateTime createdAt + +toDomain() Like + +from(like)$ LikeEntity + } + + %% Relationships + LikeController --> LikeFacade + LikeController ..> LikeResponse + LikeFacade --> MemberService : 인증 + LikeFacade --> LikeService + LikeFacade --> LikeTargetValidator : List 주입 + LikeFacade ..> LikeInfo + ProductLikeTargetValidator ..|> LikeTargetValidator + BrandLikeTargetValidator ..|> LikeTargetValidator + LikeService --> LikeRepository + LikeService --> Like + LikeService ..> LikeResult + Like --> TargetType + LikeRepositoryImpl ..|> LikeRepository + LikeRepositoryImpl --> LikeEntity + LikeEntity --> TargetType +``` + +### 핵심 포인트 + +1. **LikeTargetValidator 전략 패턴**: 대상 존재 검증을 인터페이스로 추상화하여 OCP 준수. 새로운 좋아요 대상 추가 시 Validator 구현체만 추가하면 됨 +2. **다형성 구조**: `target_id` + `target_type`으로 상품/브랜드 좋아요를 단일 테이블에서 관리 +3. **토글 로직은 LikeService**: 좋아요 존재 여부 확인 → 있으면 삭제(하드 삭제), 없으면 생성 +4. **RESTful 엔드포인트 분리**: `/products/{id}/likes`, `/brands/{id}/likes`로 리소스별 분리, 내부 로직은 통합 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **동시 토글 요청** | 같은 사용자가 빠르게 중복 클릭 시 중복 좋아요 생성 가능 | UNIQUE 제약조건 (member_id, target_id, target_type) + 예외 처리 | +| **삭제된 대상 좋아요** | 대상 삭제 후 좋아요 데이터 잔존 | 대상 삭제 시 좋아요 연쇄 삭제 또는 조회 시 필터링 | +| **좋아요 카운트 성능** | 대상별 좋아요 수 매번 COUNT 쿼리 | 대상 테이블에 like_count 컬럼 추가 (비정규화) 또는 캐시 | + +--- + +## 배송지 (MemberAddress) + +### 왜 필요한가? + +배송지 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **배송지 재사용**: 회원이 자주 쓰는 배송지를 저장하고 재사용할 수 있는가? +- **기본 배송지**: 회원별 기본 배송지 설정이 가능한가? +- **주문 시 스냅샷**: 주문에는 배송지 정보가 복사(스냅샷)되어 저장되는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class MemberAddressController { + -MemberAddressFacade memberAddressFacade + +getMyAddresses(loginId, password) ApiResponse~List~ + +createAddress(loginId, password, CreateAddressRequest) ApiResponse~AddressResponse~ + +updateAddress(loginId, password, addressId, UpdateAddressRequest) ApiResponse~AddressResponse~ + +deleteAddress(loginId, password, addressId) ApiResponse~Void~ + +setDefaultAddress(loginId, password, addressId) ApiResponse~AddressResponse~ + } + + class CreateAddressRequest { + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + +Boolean isDefault + } + + class UpdateAddressRequest { + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + } + + class AddressResponse { + +Long id + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + +Boolean isDefault + } + + %% Application Layer + class MemberAddressFacade { + -MemberService memberService + -MemberAddressService memberAddressService + +getMyAddresses(loginId, password) List~MemberAddressInfo~ + +createAddress(loginId, password, address) MemberAddressInfo + +updateAddress(loginId, password, addressId, address) MemberAddressInfo + +deleteAddress(loginId, password, addressId) void + +setDefaultAddress(loginId, password, addressId) MemberAddressInfo + } + + class MemberAddressInfo { + +Long id + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + +Boolean isDefault + } + + %% Domain Layer + class MemberAddress { + -Long id + -Long memberId + -String recipientName + -String phone + -String zipCode + -String address + -String addressDetail + -Boolean isDefault + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +update(recipientName, phone, zipCode, address, addressDetail) void + +setDefault() void + +clearDefault() void + +isDeleted() boolean + } + + class MemberAddressService { + -MemberAddressRepository memberAddressRepository + +getAddresses(memberId) List~MemberAddress~ + +getAddress(memberId, addressId) MemberAddress + +getDefaultAddress(memberId) Optional~MemberAddress~ + +createAddress(memberId, address) MemberAddress + +updateAddress(memberId, addressId, address) MemberAddress + +deleteAddress(memberId, addressId) void + +setDefaultAddress(memberId, addressId) MemberAddress + } + + %% Infrastructure Layer + class MemberAddressRepository { + <> + +findByMemberId(memberId) List~MemberAddress~ + +findByMemberIdAndId(memberId, addressId) Optional~MemberAddress~ + +findDefaultByMemberId(memberId) Optional~MemberAddress~ + +save(address) MemberAddress + +clearDefaultByMemberId(memberId) void + } + + %% Relationships + MemberAddressController --> MemberAddressFacade + MemberAddressFacade --> MemberService : 인증 + MemberAddressFacade --> MemberAddressService + MemberAddressFacade ..> MemberAddressInfo + MemberAddressService --> MemberAddressRepository + MemberAddressService --> MemberAddress +``` + +### 핵심 포인트 + +1. **회원별 배송지 목록**: 회원이 여러 배송지를 등록하고 관리 가능 +2. **기본 배송지**: `isDefault=true`인 배송지는 회원당 1개, 주문 시 자동 선택 +3. **주문과 분리**: 배송지 수정/삭제해도 기존 주문의 배송 정보에 영향 없음 (주문에 스냅샷 저장) + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **기본 배송지 동시 변경** | 여러 요청으로 기본 배송지가 2개 이상 될 수 있음 | 트랜잭션 내 clearDefault → setDefault 순서 보장 | + +> **정책 결정 완료**: 회원당 배송지 최대 5개 제한 (ADR-013) + +--- + +## 주문 (Order) + +### 왜 필요한가? + +주문 도메인의 클래스 다이어그램으로 다음을 검증한다: +- **다중 도메인 협력**: 주문 생성 시 Product, 옵션 검증 및 재고 차감이 올바르게 조합되는가? +- **스냅샷 저장**: 주문 시점의 상품명/가격/배송정보가 스냅샷으로 저장되어 원본 변경에 영향받지 않는가? +- **상태 전이 규칙**: 주문 상태 변경 시 유효한 전이만 허용되는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% Interfaces Layer + class OrderController { + -OrderFacade orderFacade + +createOrder(loginId, password, CreateOrderRequest) ApiResponse~OrderResponse~ + +getMyOrders(loginId, password, period, pageable) ApiResponse~Page~ + +getOrderDetail(loginId, password, orderId) ApiResponse~OrderDetailResponse~ + +cancelOrder(loginId, password, orderId) ApiResponse~OrderResponse~ + } + + class OrderAdminController { + -OrderFacade orderFacade + -OrderService orderService + +getOrders(filters, pageable) ApiResponse~Page~ + +getOrderDetail(orderId) ApiResponse~OrderAdminDetailResponse~ + +updateOrderStatus(orderId, UpdateOrderStatusRequest) ApiResponse~OrderResponse~ + } + + class CreateOrderRequest { + +List~OrderItemRequest~ items + +Long addressId + +String shippingMemo + } + + class OrderItemRequest { + +Long productId + +Long productOptionId + +Integer quantity + } + + class UpdateOrderStatusRequest { + +OrderStatus status + } + + class OrderResponse { + +Long id + +String orderNumber + +String orderName + +Long paymentAmount + +OrderStatus status + +LocalDateTime createdAt + } + + class OrderDetailResponse { + +Long id + +String orderNumber + +String orderName + +Long totalAmount + +Long shippingFee + +Long discountAmount + +Long paymentAmount + +OrderStatus status + +List~OrderProductInfo~ products + +ShippingInfoResponse shippingInfo + +LocalDateTime createdAt + } + + class ShippingInfoResponse { + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + +String memo + } + + class OrderAdminDetailResponse { + +Long id + +String orderNumber + +String orderName + +Long totalAmount + +Long shippingFee + +Long discountAmount + +Long paymentAmount + +OrderStatus status + +List~OrderProductInfo~ products + +ShippingInfoResponse shippingInfo + +Long memberId + +LocalDateTime createdAt + +LocalDateTime updatedAt + } + + %% Application Layer + class OrderFacade { + -MemberService memberService + -OrderService orderService + -ProductService productService + -MemberAddressService memberAddressService + +createOrder(loginId, password, items, addressId, shippingMemo) OrderInfo + +getMyOrders(loginId, password, period, pageable) Page~OrderInfo~ + +getOrderDetail(loginId, password, orderId) OrderDetailInfo + +cancelOrder(loginId, password, orderId) OrderInfo + +updateOrderStatus(orderId, status) OrderInfo + } + + class OrderInfo { + +Long id + +String orderNumber + +String orderName + +Long paymentAmount + +OrderStatus status + +LocalDateTime createdAt + } + + class OrderDetailInfo { + +OrderInfo order + +List~OrderProductInfo~ products + +ShippingInfo shippingInfo + } + + class ShippingInfo { + +String recipientName + +String phone + +String zipCode + +String address + +String addressDetail + +String memo + } + + %% Domain Layer + class Order { + -Long id + -Long memberId + -String orderNumber + -String orderName + -Long totalAmount + -Long shippingFee + -Long discountAmount + -Long paymentAmount + -OrderStatus status + -String recipientName + -String recipientPhone + -String recipientZipCode + -String recipientAddress + -String recipientAddressDetail + -String shippingMemo + -LocalDateTime createdAt + -LocalDateTime updatedAt + +calculatePaymentAmount() Long + +updateStatus(newStatus) void + +canCancel() boolean + +cancel() void + +isOwner(memberId) boolean + } + + class OrderStatus { + <> + PENDING + PAID + PREPARING + SHIPPING + DELIVERED + CANCELLED + RETURNED + +canTransitionTo(newStatus) boolean + } + + class OrderProduct { + -Long id + -Long orderId + -Long productId + -Long productOptionId + -String productName + -String optionValue + -Long price + -Long extraPrice + -Integer quantity + -OrderProductStatus status + -LocalDateTime createdAt + -LocalDateTime updatedAt + +getTotalPrice() Long + +cancel() void + } + + class OrderProductStatus { + <> + NORMAL + CANCEL_REQUESTED + CANCELLED + RETURN_REQUESTED + RETURNED + } + + class OrderPeriod { + <> + THREE_MONTHS + SIX_MONTHS + ONE_YEAR + ALL + } + + class OrderService { + -OrderRepository orderRepository + -OrderProductRepository orderProductRepository + +createOrder(memberId, orderProducts, shippingSnapshot) Order + +getOrder(orderId) Order + +getOrdersByMemberId(memberId, period, pageable) Page~Order~ + +getOrders(filters, pageable) Page~Order~ + +getOrderProducts(orderId) List~OrderProduct~ + +updateOrderStatus(orderId, status) Order + +cancelOrder(orderId) Order + +validateOrderOwner(orderId, memberId) void + } + + %% Infrastructure Layer + class OrderRepository { + <> + +findById(orderId) Optional~Order~ + +findByMemberId(memberId, period, pageable) Page~Order~ + +findAll(filters, pageable) Page~Order~ + +save(order) Order + } + + class OrderProductRepository { + <> + +findByOrderId(orderId) List~OrderProduct~ + +saveAll(orderProducts) List~OrderProduct~ + } + + %% Relationships + OrderController --> OrderFacade + OrderAdminController --> OrderFacade + OrderAdminController --> OrderService : 목록 조회 + OrderFacade --> MemberService : 인증 + OrderFacade --> OrderService + OrderFacade --> ProductService : 상품/옵션 검증, 재고 차감/복구 + OrderFacade --> MemberAddressService : 배송지 조회 + OrderService --> OrderRepository + OrderService --> OrderProductRepository + OrderService --> Order + Order --> OrderStatus + Order "1" --> "*" OrderProduct : contains + OrderProduct --> OrderProductStatus +``` + +### 핵심 포인트 + +1. **Facade 필수**: 주문 생성은 반드시 `OrderFacade` 경유 (배송지 조회 → 옵션 검증/재고 차감 → 주문 생성) +2. **배송 정보 스냅샷**: `Order`에 `recipientName`, `recipientPhone`, `recipientAddress` 등이 직접 저장 (배송지 수정에 영향 없음) +3. **addressId로 배송지 선택**: 주문 시 `MemberAddressService`에서 배송지 조회 후 스냅샷 복사 +4. **상태 전이 검증**: `OrderStatus.canTransitionTo()`로 유효한 상태 변경만 허용 +5. **취소 시 재고 복구 통합**: 사용자 `cancelOrder()`와 관리자 `updateOrderStatus(CANCELLED)` 모두 `ProductService.increaseStock()`으로 재고 복구 처리 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **재고 차감 동시성** | 동시 주문 시 재고 초과 판매 가능 | ProductService에서 비관적 락 또는 분산 락 적용 | +| **트랜잭션 범위 비대화** | 옵션 재고 차감 + 주문 생성이 하나의 트랜잭션 | 현재는 허용, 규모 커지면 Saga 패턴 검토 | +| **부분 취소 복잡성** | 여러 상품 중 일부만 취소 시 금액 재계산 | 현재는 전체 취소만 지원, 부분 취소는 추후 설계 | + +--- + +## 공통 (Support) + +### 왜 필요한가? + +공통 모듈의 클래스 다이어그램으로 다음을 검증한다: +- **일관된 응답 포맷**: 모든 API가 동일한 `ApiResponse` 구조를 사용하는가? +- **에러 처리 체계**: `CoreException` + `ErrorType`으로 타입 기반 예외 처리가 가능한가? +- **공통 엔티티 패턴**: `BaseEntity`로 감사(audit) 필드와 소프트 삭제가 통일되는가? + +### 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + class ApiResponse~T~ { + -Meta meta + -T data + +success(data)$ ApiResponse~T~ + +success()$ ApiResponse~Void~ + +fail(errorCode, message)$ ApiResponse~Void~ + } + + class Meta { + -String result + -String errorCode + -String message + } + + class GlobalExceptionHandler { + +handleCoreException(CoreException) ApiResponse~Void~ + +handleValidationException(MethodArgumentNotValidException) ApiResponse~Void~ + +handleException(Exception) ApiResponse~Void~ + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + +getCustomMessage() String + } + + class ErrorType { + <> + BAD_REQUEST + UNAUTHORIZED + FORBIDDEN + NOT_FOUND + CONFLICT + INTERNAL_ERROR + -HttpStatus status + -String code + -String message + +getStatus() HttpStatus + +getCode() String + +getMessage() String + } + + class BaseEntity { + <> + -Long id + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + +isDeleted() boolean + +delete() void + } + + ApiResponse --> Meta + GlobalExceptionHandler --> CoreException + GlobalExceptionHandler --> ApiResponse + CoreException --> ErrorType +``` + +### 핵심 포인트 + +1. **ApiResponse 래핑**: 모든 응답은 `ApiResponse`로 래핑, `meta.result`로 성공/실패 구분 +2. **ErrorType 기반 예외**: `CoreException(ErrorType.NOT_FOUND)` 형태로 예외 발생, `GlobalExceptionHandler`에서 일괄 처리 +3. **BaseEntity 상속**: 모든 Entity가 `id`, `createdAt`, `updatedAt`, `deletedAt` 공통 필드 상속 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **ErrorType 확장 시 코드 중복** | 도메인별 세부 에러가 많아지면 ErrorType enum 비대화 | 도메인별 ErrorType 분리 또는 에러 코드 문자열 조합 방식 | +| **customMessage 남용** | 에러 메시지가 일관되지 않을 수 있음 | 기본 message 우선 사용, customMessage는 예외 상황에만 | +| **deletedAt null 체크 누락** | 소프트 삭제된 데이터 조회 가능 | Repository에서 `@Where(clause = "deleted_at IS NULL")` 적용 | + +--- + +## 도메인 관계도 + +### 왜 필요한가? + +전체 도메인 간 관계를 한눈에 파악하기 위한 조감도: +- **FK 관계 확인**: 각 도메인이 어떤 도메인을 참조하는가? +- **순환 의존 검증**: 도메인 간 순환 참조가 없는가? +- **결합도 파악**: 어떤 도메인이 가장 많은 의존을 받는가? (변경 시 영향 범위) + +### 도메인 관계 다이어그램 + +```mermaid +classDiagram + direction LR + + class Member { + +Long id + +String loginId + } + + class MemberAddress { + +Long id + +Long memberId + +Boolean isDefault + } + + class Brand { + +Long id + +String name + } + + class Category { + +Long id + +String name + +Long parentId + } + + class Product { + +Long id + +Long brandId + +Long categoryId + } + + class ProductOption { + +Long id + +Long productId + } + + class Like { + +Long memberId + +Long targetId + +TargetType targetType + } + + class Order { + +Long id + +Long memberId + +recipientName + +recipientAddress + } + + class OrderProduct { + +Long orderId + +Long productId + +Long productOptionId + } + + Member "1" --> "*" MemberAddress : has + Member "1" --> "*" Like : likes + Member "1" --> "*" Order : places + Brand "1" --> "*" Product : has + Category "1" --> "*" Product : contains + Category "1" --> "*" Category : parent + Product "1" --> "*" ProductOption : has + Order "1" --> "*" OrderProduct : contains + OrderProduct "*" --> "1" Product : references + OrderProduct "*" --> "1" ProductOption : references +``` + +### 핵심 포인트 + +1. **Product가 중심 도메인**: Brand, Category, OrderProduct 등 가장 많은 참조를 받음 → 변경 시 영향 범위 큼 +2. **Like 다형성 구조**: Like는 `targetId` + `targetType`으로 Product 또는 Brand를 참조하므로 직접 FK 없음 +3. **순환 의존 없음**: 모든 화살표가 단방향, Category 자기 참조(parent)만 존재 +4. **배송지 주소록 + 스냅샷**: `MemberAddress`는 회원의 배송지 목록, `Order`에는 주문 시점의 배송 정보가 스냅샷으로 저장 + +### 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| **Product 도메인 비대화** | Product 변경 시 여러 도메인에 영향 | 이벤트 기반 느슨한 결합 또는 인터페이스 분리 | +| **OrderProduct 스냅샷 의존** | Product/Option 삭제 시 참조 무결성 | Soft Delete 강제 + FK 제약조건 완화 | +| **Category 자기 참조 깊이** | 무한 깊이 허용 시 조회 성능 저하 | depth 제한 (예: 최대 3단계) 정책 | \ No newline at end of file diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..9fd5de1be --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,145 @@ +## ERDiagram +```mermaid +erDiagram + +members { +id bigint PK +varchar(50) login_id UK "NOT NULL" +varchar(255) password "NOT NULL" +varchar(30) name "NOT NULL" +date birthday "NOT NULL | YYYY-MM-DD" +varchar(50) email UK "NOT NULL" +datetime created_at +datetime updated_at +} + +member_addresses { +bigint id PK +bigint member_id FK "NOT NULL | 회원 ID" +varchar(50) recipient_name "NOT NULL | 수령인 이름" +varchar(20) phone "NOT NULL | 연락처" +varchar(10) zip_code "우편번호" +varchar(255) address "NOT NULL | 배송 주소" +varchar(255) address_detail "상세 주소" +boolean is_default "기본 배송지 여부" +datetime created_at +datetime updated_at +} + +products { +bigint id PK +varchar(100) name "NOT NULL | 상품명" +varchar(20) product_code UK "NOT NULL | 상품 코드 ({YYYYMMDD}-{5자리 순번}, 예: 20240101-00001)" +bigint base_price "NOT NULL | 기본 판매가격" +enum status "NOT NULL | 상품 상태(SALE, STOP, SOLDOUT)" +bigint brand_id FK "NOT NULL | 브랜드 ID" +bigint category_id FK "NOT NULL | 카테고리 ID" +bigint discount "할인 금액, 할인율" +enum discount_type "PRICE, RATE" +bigint like_count "좋아요 수 (기본값: 0)" +datetime created_at +datetime updated_at +datetime deleted_at +} + +product_images { +bigint id PK +bigint product_id FK "NOT NULL | 상품 ID" +enum type "이미지 타입(MAIN, SUB, DETAIL)" +varchar(512) url "NOT NULL | 이미지 URL" +varchar(255) alt_text "대체 텍스트" +datetime created_at +datetime updated_at +} + +product_options { +bigint id PK +bigint product_id FK "NOT NULL | 상품 ID" +varchar(50) option_value "NOT NULL | 옵션값 (예: RED, L)" +varchar(255) display_name "노출 옵션값 (예: 빨강, Large) 빈칸이면 option_value 표시" +bigint extra_price "추가 금액 (기본값: 0)" +int stock_quantity "현재고" +datetime created_at +datetime updated_at +datetime deleted_at +} + + +brands { +bigint id PK +varchar(50) name "NOT NULL | 브랜드명" +text description "브랜드 설명" +varchar(512) logo_image_url "로고 이미지 URL" +bigint like_count "좋아요 수 (기본값: 0)" +datetime created_at +datetime updated_at +datetime deleted_at +} + +categories { +bigint id PK +bigint parent_id "부모 카테고리 ID" +varchar(20) name "NOT NULL | 카테고리명" +varchar(255) path "전체 경로 (예 : 1/5/10)" +int depth "계층 레벨(0, 1, 2...)" +datetime created_at +datetime updated_at +datetime deleted_at +} + +likes { +bigint id PK +bigint member_id FK "NOT NULL | 회원 ID | UNIQUE(member_id, target_id, target_type)" +bigint target_id "NOT NULL | 대상 ID (products.id 또는 brands.id) | UNIQUE(member_id, target_id, target_type)" +enum target_type "NOT NULL | 대상 타입 (PRODUCT, BRAND) | UNIQUE(member_id, target_id, target_type)" +datetime created_at +} + +orders { +bigint id PK +bigint member_id FK "NOT NULL | 주문자 ID" +varchar(20) order_number UK "NOT NULL | 주문 번호 (예: ORD20231027-1234567)" +varchar(100) order_name "주문 상품 요약 (예: 아이폰 15 외 2건)" +bigint total_amount "총 상품 금액" +bigint shipping_fee "배송비" +bigint discount_amount "할인 금액" +bigint payment_amount "최종 결제 금액" +enum status "주문 상태 (PENDING, PAID, PREPARING, SHIPPING, DELIVERED, CANCELLED, RETURNED)" +varchar(50) recipient_name "수령인 이름 (스냅샷)" +varchar(20) recipient_phone "연락처 (스냅샷)" +varchar(10) recipient_zip_code "우편번호 (스냅샷)" +varchar(255) recipient_address "배송 주소 (스냅샷)" +varchar(255) recipient_address_detail "상세 주소 (스냅샷)" +varchar(500) shipping_memo "배송 메모" +datetime created_at "주문 일시" +datetime updated_at +} + +order_products { +bigint id PK +bigint order_id FK "NOT NULL | 주문 ID" +bigint product_id FK "NOT NULL | 상품 ID" +bigint product_option_id FK "NOT NULL | 주문한 옵션 ID" +varchar(100) product_name "주문 당시 상품명" +varchar(100) option_value "주문 당시 옵션값 (예: 빨강) (스냅샷)" +bigint price "주문 당시 판매가" +bigint extra_price "주문 당시 옵션 추가금" +int quantity "주문 수량" +varchar(500) thumbnail_url "주문 당시 상품 썸네일 URL (스냅샷)" +enum status "주문 상품 상태 (NORMAL, CANCEL_REQUESTED, CANCELLED, RETURN_REQUESTED, RETURNED)" +datetime created_at +datetime updated_at +} + +product_images }o--|| products : "belongs to" +product_options }o--|| products : "belongs to" +products }o--|| brands : "brand" +products }o--|| categories : "category" +categories }o--|| categories : "parent" +members ||--o{ likes: "좋아요" +members ||--o{ orders: "주문" +orders ||--|{ order_products: "주문 상품" +order_products }o--|| products: "상품 참조" +order_products }o--|| product_options: "옵션 참조" +members ||--o{ member_addresses: "배송지 주소록" +``` \ No newline at end of file diff --git a/docs/member-erd.md b/docs/member-erd.md new file mode 100644 index 000000000..b69baed5a --- /dev/null +++ b/docs/member-erd.md @@ -0,0 +1,34 @@ +# Member ERD + +## 회원 테이블 설계 + +```mermaid +erDiagram + MEMBER { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR(30) login_id UK "NOT NULL, 로그인 ID" + VARCHAR(255) password "NOT NULL, 암호화 저장" + VARCHAR(50) name "NOT NULL, 이름" + DATE birthday "NOT NULL, 생년월일" + VARCHAR(100) email UK "NOT NULL, 이메일" + DATETIME created_at "NOT NULL, 생성일시" + DATETIME updated_at "NOT NULL, 수정일시" + DATETIME deleted_at "NULLABLE, 삭제일시" + } +``` + +## 제약조건 + +| 제약조건 | 대상 컬럼 | 설명 | +|----------|-----------|------| +| PRIMARY KEY | `id` | AUTO_INCREMENT | +| UNIQUE | `login_id` | 로그인 ID 중복 방지 | +| UNIQUE | `email` | 이메일 중복 방지 | +| NOT NULL | `login_id`, `password`, `name`, `birthday`, `email` | 필수 입력 | + +## 비고 + +- `password`는 BCrypt 등으로 암호화하여 저장 (VARCHAR 255) +- `birthday`는 `LocalDate` 매핑 (시분초 불필요) +- `created_at`, `updated_at`, `deleted_at`은 `BaseEntity`에서 자동 관리 +- `deleted_at`을 통한 소프트 삭제 지원 diff --git a/docs/member-profile-lookup-design.md b/docs/member-profile-lookup-design.md new file mode 100644 index 000000000..7e11db1f2 --- /dev/null +++ b/docs/member-profile-lookup-design.md @@ -0,0 +1,174 @@ +# 내 정보 조회 기능 설계 + +## 요청/응답 스펙 + +| 항목 | 값 | +|------|---| +| Method | GET | +| URL | `/api/v1/members/me` | +| 인증 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 | +| 응답 코드 (성공) | 200 OK | +| 응답 코드 (인증 실패) | 401 Unauthorized | + +### 응답 필드 + +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 | +| birthday | String | 생년월일 (yyyy-MM-dd) | +| email | String | 이메일 | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: GET /api/v1/members/me
X-Loopers-LoginId / X-Loopers-LoginPw + + Controller->>Facade: getMyInfo(loginId, password) + + Note over Facade: 1. 회원 조회 + Facade->>Service: authenticate(loginId, password) + Service->>Repository: findByLoginId(loginId) + Repository->>RepoImpl: findByLoginId(loginId) + RepoImpl->>JpaRepo: findByLoginId(loginId) + JpaRepo-->>RepoImpl: Optional~MemberEntity~ + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member + RepoImpl-->>Service: Optional~Member~ + + alt 회원 미존재 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Note over Service: 2. 비밀번호 검증 + Service->>PasswordEncoder: matches(rawPassword, encodedPassword) + PasswordEncoder-->>Service: boolean + + alt 비밀번호 불일치 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Service-->>Facade: Member + + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MyInfoResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MemberV1ApiSpec { + <> + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MyInfoResponse { + <> + +String loginId + +String name + +String birthday + +String email + +from(MemberInfo) MyInfoResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +getMyInfo(loginId, password) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +LocalDate birthday + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + } + + class MemberRepository { + <> + +findByLoginId(String) Optional~Member~ + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +authenticate(loginId, password) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +toDomain() Member + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +findByLoginId(String) Optional~Member~ + } + + class MemberJpaRepository { + <> + +findByLoginId(String) Optional~MemberEntity~ + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MyInfoResponse + MyInfoResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member +``` \ No newline at end of file diff --git a/docs/member-signup-design.md b/docs/member-signup-design.md new file mode 100644 index 000000000..ec9cdf7b9 --- /dev/null +++ b/docs/member-signup-design.md @@ -0,0 +1,264 @@ +# 회원가입 기능 설계 + +## 검증 규칙 + +| 필드 | 규칙 | +|------|------| +| loginId | NOT NULL, NOT BLANK | +| password | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용, 생년월일(yyyyMMdd) 포함 불가 | +| name | NOT NULL, NOT BLANK, 한글 2~20자 | +| birthday | NOT NULL, `yyyy-MM-dd` 형식, 미래 날짜 불가 | +| email | NOT NULL, 이메일 형식 (RFC 5322) | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Member as Member (Domain) + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signUp(request) + Facade->>Service: signUp(loginId, password, name, birthday, email) + + Note over Service: 1. loginId 중복 검사 + Service->>Repository: existsByLoginId(loginId) + Repository->>RepoImpl: existsByLoginId(loginId) + RepoImpl->>JpaRepo: existsByLoginId(loginId) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt loginId 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 2. email 중복 검사 + Service->>Repository: existsByEmail(email) + Repository->>RepoImpl: existsByEmail(email) + RepoImpl->>JpaRepo: existsByEmail(email) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt email 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 3. 도메인 객체 생성 (필드 검증은 생성자에서 수행) + Service->>Member: new Member(loginId, rawPassword, name, birthday, email) + Note over Member: 이름 형식 검증 (한글 2~20자)
생년월일 형식 검증 (미래 날짜 불가)
이메일 형식 검증
비밀번호 규칙 검증
(8~16자, 문자종류, 생년월일 포함 여부) + + alt 검증 실패 + Member-->>Service: CoreException (BAD_REQUEST) + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request + end + + Note over Service: 4. 비밀번호 암호화 + Service->>PasswordEncoder: encode(rawPassword) + PasswordEncoder-->>Service: encodedPassword + Service->>Member: encryptPassword(encodedPassword) + + Note over Service: 5. 저장 + Service->>Repository: save(member) + Repository->>RepoImpl: save(member) + Note over RepoImpl: Domain → Persistence 변환 + RepoImpl->>Entity: MemberEntity.from(member) + Entity-->>RepoImpl: MemberEntity + RepoImpl->>JpaRepo: save(memberEntity) + JpaRepo-->>RepoImpl: MemberEntity + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member + RepoImpl-->>Service: Member + + Service-->>Facade: Member + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1ApiSpec { + <> + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1Dto { + <> + } + + class SignUpRequest { + <> + +String loginId + +String password + +String name + +String birthday + +String email + } + + class SignUpResponse { + <> + +Long id + +String loginId + +String name + +String email + +from(MemberInfo) SignUpResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +signUp(SignUpRequest) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +Member(loginId, rawPassword, name, birthday, email) + +encryptPassword(encodedPassword) + -validateLoginId(loginId) + -validatePassword(rawPassword, birthday) + -validateName(name) + -validateBirthday(birthday) + -validateEmail(email) + } + + class MemberRepository { + <> + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +signUp(loginId, password, name, birthday, email) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +from(Member)$ MemberEntity + +toDomain() Member + } + + class BaseEntity { + <> + -Long id + -ZonedDateTime createdAt + -ZonedDateTime updatedAt + -ZonedDateTime deletedAt + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberJpaRepository { + <> + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class JpaRepository~T~ { + <> + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MemberV1Dto + MemberV1Dto *-- SignUpRequest + MemberV1Dto *-- SignUpResponse + SignUpResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member + BaseEntity <|-- MemberEntity + JpaRepository~T~ <|-- MemberJpaRepository +``` + +## 패키지 구조 + +``` +com.loopers +├── interfaces/api/member/ +│ ├── MemberV1Controller ← REST 엔드포인트 +│ ├── MemberV1Dto ← SignUpRequest, SignUpResponse (record) +│ └── MemberV1ApiSpec ← Swagger 스펙 인터페이스 +├── application/member/ +│ ├── MemberFacade ← 유즈케이스 오케스트레이션 +│ └── MemberInfo ← 응답 변환용 record +├── domain/member/ +│ ├── Member ← 도메인 엔티티 (순수 Java 객체, 검증 로직 포함) +│ ├── MemberRepository ← 도메인 레포지토리 인터페이스 +│ └── MemberService ← 비즈니스 로직 (중복 검사, 암호화 위임) +└── infrastructure/member/ + ├── MemberEntity ← JPA 영속성 엔티티 (BaseEntity 상속) + ├── MemberRepositoryImpl ← 레포지토리 구현체 (Domain ↔ Entity 변환) + └── MemberJpaRepository ← Spring Data JPA 인터페이스 +``` + +## Domain Entity vs Persistence Entity 분리 + +| 구분 | Member (Domain) | MemberEntity (Persistence) | +|------|-----------------|---------------------------| +| 위치 | `domain/member/` | `infrastructure/member/` | +| 역할 | 비즈니스 검증 로직 | DB 영속화 | +| 상속 | 없음 (순수 Java 객체) | `BaseEntity` 상속 | +| JPA 어노테이션 | 없음 | `@Entity`, `@Table`, `@Column` | +| 변환 | - | `from(Member)`, `toDomain()` | diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 3edc872a17400fb1450ccc97c5dafb3b7e92b575 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:09:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member Entity, Repository, Service 계층 구현 - MemberFacade 유즈케이스 오케스트레이션 - 회원가입 API (loginId 중복 검증, 비밀번호 암호화) - 로그인 API (인증 처리) - 프로필 조회 API - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 33 ++ .../application/member/MemberInfo.java | 22 + .../com/loopers/domain/member/Member.java | 87 ++++ .../domain/member/MemberRepository.java | 11 + .../loopers/domain/member/MemberService.java | 62 +++ .../infrastructure/member/MemberEntity.java | 69 +++ .../member/MemberJpaRepository.java | 11 + .../member/MemberRepositoryImpl.java | 49 +++ .../api/member/MemberV1ApiSpec.java | 27 ++ .../api/member/MemberV1Controller.java | 71 +++ .../interfaces/api/member/MemberV1Dto.java | 67 +++ .../application/member/MemberFacadeTest.java | 99 +++++ .../application/member/MemberInfoTest.java | 71 +++ .../domain/member/MemberServiceTest.java | 212 +++++++++ .../com/loopers/domain/member/MemberTest.java | 149 +++++++ .../member/MemberEntityTest.java | 60 +++ .../MemberRepositoryImplIntegrationTest.java | 159 +++++++ .../api/member/MemberV1ApiE2ETest.java | 406 ++++++++++++++++++ http/commerce-api/member-v1.http | 27 ++ 19 files changed, 1692 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java create mode 100644 http/commerce-api/member-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..9c958743b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,33 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + @Transactional + public MemberInfo signUp(String loginId, String password, String name, LocalDate birthday, String email) { + Member member = memberService.signUp(loginId, password, name, birthday, email); + return MemberInfo.from(member); + } + + @Transactional(readOnly = true) + public MemberInfo getMyInfo(String loginId, String password) { + Member member = memberService.authenticate(loginId, password); + return MemberInfo.from(member).withMaskedName(); + } + + @Transactional + public void updatePassword(String loginId, String currentPassword, String newPassword) { + memberService.updatePassword(loginId, currentPassword, newPassword); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..d5b491d21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,22 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +import java.time.LocalDate; + +public record MemberInfo(Long id, String loginId, String name, LocalDate birthday, String email) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthday(), + member.getEmail() + ); + } + + public MemberInfo withMaskedName() { + String maskedName = name.substring(0, name.length() - 1) + "*"; + return new MemberInfo(id, loginId, maskedName, birthday, email); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..19fbd7193 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,87 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class Member { + + private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private Long id; + private String loginId; + private String password; + private String name; + private LocalDate birthday; + private String email; + + public Member(String loginId, String password, String name, LocalDate birthday, String email) { + validateBirthday(birthday); + validatePasswordNotContainsBirthday(password, birthday); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { + this.id = id; + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + public void encryptPassword(String encodedPassword) { + this.password = encodedPassword; + } + + public void changePassword(String newRawPassword, String newEncodedPassword) { + validatePasswordNotContainsBirthday(newRawPassword, this.birthday); + this.password = newEncodedPassword; + } + + private void validateBirthday(LocalDate birthday) { + if (birthday != null && birthday.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); + } + } + + private void validatePasswordNotContainsBirthday(String password, LocalDate birthday) { + if (password != null && birthday != null) { + String birthdayStr = birthday.format(BIRTHDAY_FORMATTER); + if (password.contains(birthdayStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + } + + public Long getId() { + return id; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..5fc681140 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + Member save(Member member); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); + Optional findByLoginId(String loginId); + void updatePassword(String loginId, String encodedPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 000000000..243947a8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,62 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Member authenticate(String loginId, String rawPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(rawPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return member; + } + + @Transactional(propagation = Propagation.REQUIRED) + public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT); + } + if (memberRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT); + } + + Member member = new Member(loginId, password, name, birthday, email); + member.encryptPassword(passwordEncoder.encode(password)); + return memberRepository.save(member); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void updatePassword(String loginId, String currentPassword, String newPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(currentPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + String encodedNewPassword = passwordEncoder.encode(newPassword); + member.changePassword(newPassword, encodedNewPassword); + memberRepository.updatePassword(loginId, member.getPassword()); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java new file mode 100644 index 000000000..4e01e40ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "members") +public class MemberEntity extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true, length = 50) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false, length = 30) + private String name; + + @Column(name = "birthday", nullable = false) + private LocalDate birthday; + + @Column(name = "email", nullable = false, unique = true, length = 50) + private String email; + + protected MemberEntity() {} + + public static MemberEntity from(Member member) { + MemberEntity entity = new MemberEntity(); + entity.loginId = member.getLoginId(); + entity.password = member.getPassword(); + entity.name = member.getName(); + entity.birthday = member.getBirthday(); + entity.email = member.getEmail(); + return entity; + } + + public Member toDomain() { + return new Member(getId(), loginId, password, name, birthday, email); + } + + public void updatePassword(String password) { + this.password = password; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..840bca727 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); + Optional findByLoginId(String loginId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..090d714dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + MemberEntity entity = MemberEntity.from(member); + MemberEntity saved = memberJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } + + @Override + public boolean existsByEmail(String email) { + return memberJpaRepository.existsByEmail(email); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId) + .map(MemberEntity::toDomain); + } + + @Override + @Transactional + public void updatePassword(String loginId, String encodedPassword) { + MemberEntity entity = memberJpaRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + entity.updatePassword(encodedPassword); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..faaec90be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 회원을 등록합니다." + ) + ApiResponse signUp(MemberV1Dto.SignUpRequest request); + + @Operation( + summary = "내 정보 조회", + description = "인증된 회원의 정보를 조회합니다." + ) + ApiResponse getMyInfo(String loginId, String password); + + @Operation( + summary = "비밀번호 변경", + description = "인증된 회원의 비밀번호를 변경합니다." + ) + ApiResponse updatePassword(String loginId, String password, MemberV1Dto.UpdatePasswordRequest request); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..57ed951e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp( + @Valid @RequestBody MemberV1Dto.SignUpRequest request + ) { + LocalDate birthday = LocalDate.parse(request.birthday()); + MemberInfo info = memberFacade.signUp( + request.loginId(), + request.password(), + request.name(), + birthday, + request.email() + ); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @Valid @RequestBody MemberV1Dto.UpdatePasswordRequest request + ) { + if (!password.equals(request.currentPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "인증 정보가 일치하지 않습니다."); + } + memberFacade.updatePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..47bf6e001 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,67 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 입력 가능합니다.") + String loginId, + + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$", message = "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다.") + String password, + + @NotBlank(message = "이름은 비어있을 수 없습니다.") + @Pattern(regexp = "^[가-힣]{2,20}$", message = "이름은 한글 2~20자여야 합니다.") + String name, + + @NotBlank(message = "생년월일은 비어있을 수 없습니다.") + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd)") + String birthday, + + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email + ) { + } + + public record SignUpResponse(Long id, String loginId, String name, String email) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse( + info.id(), + info.loginId(), + info.name(), + info.email() + ); + } + } + + public record UpdatePasswordRequest( + @NotBlank(message = "현재 비밀번호는 비어있을 수 없습니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 비어있을 수 없습니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$", message = "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다.") + String newPassword + ) { + } + + public record MyInfoResponse(String loginId, String name, String birthday, String email) { + public static MyInfoResponse from(MemberInfo info) { + return new MyInfoResponse( + info.loginId(), + info.name(), + info.birthday().toString(), + info.email() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 000000000..9d5aae352 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,99 @@ +package com.loopers.application.member; + +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 com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberFacadeTest { + + private final MemberFacade memberFacade; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberFacadeTest( + MemberFacade memberFacade, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberFacade = memberFacade; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 요청할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, MemberInfo를 반환한다.") + @Test + void signUp_withValidInfo_returnsMemberInfo() { + // arrange + String loginId = "testuser1"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + MemberInfo info = memberFacade.signUp(loginId, password, name, birthday, email); + + // assert + assertAll( + () -> assertThat(info.id()).isNotNull(), + () -> assertThat(info.loginId()).isEqualTo(loginId), + () -> assertThat(info.name()).isEqualTo(name), + () -> assertThat(info.birthday()).isEqualTo(birthday), + () -> assertThat(info.email()).isEqualTo(email) + ); + } + } + + @DisplayName("내 정보를 조회할 때,") + @Nested + class GetMyInfo { + + @DisplayName("올바른 자격 증명으로 조회하면, MemberInfo를 반환한다.") + @Test + void getMyInfo_withValidCredentials_returnsMemberInfo() { + // arrange + String rawPassword = "Test1234!"; + Member member = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(member); + + // act + MemberInfo info = memberFacade.getMyInfo("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길*"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java new file mode 100644 index 000000000..a704ec104 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java @@ -0,0 +1,71 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberInfoTest { + + @DisplayName("MemberInfo 변환 시,") + @Nested + class From { + + @DisplayName("Member 도메인 객체로부터 MemberInfo를 생성하면, password를 제외한 정보를 포함한다.") + @Test + void createsMemberInfo_fromDomain_withoutPassword() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + + // act + MemberInfo info = MemberInfo.from(member); + + // assert + assertAll( + () -> assertThat(info.id()).isEqualTo(1L), + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("이름 마스킹 시,") + @Nested + class MaskName { + + @DisplayName("3자 이상 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasThreeOrMoreCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍길*"); + } + + @DisplayName("2자 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasTwoCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍*"); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 000000000..60d94f47e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,212 @@ +package com.loopers.domain.member; + +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 org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberServiceTest { + + private final MemberService memberService; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberServiceTest( + MemberService memberService, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberService = memberService; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 회원이 저장되고 비밀번호가 암호화된다.") + @Test + void signUp_withValidInfo_savesMemberWithEncryptedPassword() { + // arrange + String loginId = "testuser1"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + Member savedMember = memberService.signUp(loginId, rawPassword, name, birthday, email); + + // assert + assertAll( + () -> assertThat(savedMember.getId()).isNotNull(), + () -> assertThat(savedMember.getLoginId()).isEqualTo(loginId), + () -> assertThat(savedMember.getName()).isEqualTo(name), + () -> assertThat(savedMember.getBirthday()).isEqualTo(birthday), + () -> assertThat(savedMember.getEmail()).isEqualTo(email), + () -> assertThat(savedMember.getPassword()).isNotEqualTo(rawPassword), + () -> assertThat(passwordEncoder.matches(rawPassword, savedMember.getPassword())).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateLoginId_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser1", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "other@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + + @DisplayName("이미 존재하는 email로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateEmail_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser2", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "test@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + } + + @DisplayName("인증을 할 때,") + @Nested + class Authenticate { + + @DisplayName("올바른 loginId와 password로 인증하면, 회원을 반환한다.") + @Test + void authenticate_withValidCredentials_returnsMember() { + // arrange + String rawPassword = "Test1234!"; + Member existing = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(existing); + + // act + Member result = memberService.authenticate("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.getName()).isEqualTo("홍길동"), + () -> assertThat(result.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 인증하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withNonExistentLoginId_throwsUnauthorized() { + // act & assert + assertThatThrownBy(() -> memberService.authenticate("nonexistent", "Test1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withWrongPassword_throwsUnauthorized() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.authenticate("testuser1", "Wrong1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경하면, 새 비밀번호로 인증이 가능하다.") + @Test + void updatesPassword_whenValidCurrentAndNewPassword() { + // arrange + String currentPassword = "Test1234!"; + String newPassword = "NewPass123!"; + saveMember("testuser1", currentPassword); + + // act + memberService.updatePassword("testuser1", currentPassword, newPassword); + + // assert + Member authenticated = memberService.authenticate("testuser1", newPassword); + assertThat(authenticated.getLoginId()).isEqualTo("testuser1"); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdNotFound() { + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("nonexistent", "Test1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenCurrentPasswordIsWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", "Wrong1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + String password = "Test1234!"; + saveMember("testuser1", password); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", password, password)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..b3799bdb2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.member; + +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 java.time.LocalDate; + +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 MemberTest { + + private static final String VALID_LOGIN_ID = "testuser1"; + private static final String VALID_PASSWORD = "Test1234!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTHDAY = LocalDate.of(1995, 3, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @DisplayName("회원을 생성할 때,") + @Nested + class Create { + + @DisplayName("모든 값이 유효하면, 정상적으로 생성된다.") + @Test + void createsMember_whenAllFieldsAreValid() { + // arrange & act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(member.getPassword()).isEqualTo(VALID_PASSWORD), + () -> assertThat(member.getName()).isEqualTo(VALID_NAME), + () -> assertThat(member.getBirthday()).isEqualTo(VALID_BIRTHDAY), + () -> assertThat(member.getEmail()).isEqualTo(VALID_EMAIL) + ); + } + } + + @DisplayName("비밀번호를 검증할 때,") + @Nested + class ValidatePassword { + + @DisplayName("생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthday() { + // arrange + LocalDate birthday = LocalDate.of(1995, 3, 15); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "A19950315!", VALID_NAME, birthday, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("생년월일을 검증할 때,") + @Nested + class ValidateBirthday { + + @DisplayName("오늘 날짜(경계값)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenBirthdayIsToday() { + // arrange + LocalDate today = LocalDate.now(); + + // act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, today, VALID_EMAIL); + + // assert + assertThat(member.getBirthday()).isEqualTo(today); + } + + @DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthdayIsFuture() { + // arrange + LocalDate futureDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, futureDate, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 암호화할 때,") + @Nested + class EncryptPassword { + + @DisplayName("암호화된 비밀번호로 교체된다.") + @Test + void replacesPasswordWithEncoded() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String encodedPassword = "$2a$10$encodedPasswordHash"; + + // act + member.encryptPassword(encodedPassword); + + // assert + assertThat(member.getPassword()).isEqualTo(encodedPassword); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("유효한 새 비밀번호로 변경하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenNewPasswordIsValid() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String newEncodedPassword = "$2a$10$newEncodedPasswordHash"; + + // act + member.changePassword("NewPass123!", newEncodedPassword); + + // assert + assertThat(member.getPassword()).isEqualTo(newEncodedPassword); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordContainsBirthday() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("A19950315!", "encoded") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java new file mode 100644 index 000000000..d75929da9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberEntityTest { + + @DisplayName("MemberEntity 변환 시,") + @Nested + class Convert { + + @DisplayName("Member 도메인 객체로부터 MemberEntity를 생성한다.") + @Test + void createsMemberEntity_fromDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + + // act + MemberEntity entity = MemberEntity.from(member); + + // assert + assertAll( + () -> assertThat(entity.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(entity.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(entity.getName()).isEqualTo("홍길동"), + () -> assertThat(entity.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(entity.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("MemberEntity를 Member 도메인 객체로 변환한다.") + @Test + void convertsToDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + MemberEntity entity = MemberEntity.from(member); + + // act + Member domain = entity.toDomain(); + + // assert + assertAll( + () -> assertThat(domain.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(domain.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(domain.getName()).isEqualTo("홍길동"), + () -> assertThat(domain.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(domain.getEmail()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java new file mode 100644 index 000000000..dd0410bf3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java @@ -0,0 +1,159 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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 java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryImplIntegrationTest { + + private final MemberRepository memberRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberRepositoryImplIntegrationTest( + MemberRepository memberRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.memberRepository = memberRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Member createMember(String loginId, String email) { + Member member = new Member(loginId, "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), email); + member.encryptPassword("$2a$10$encodedHash"); + return member; + } + + @DisplayName("회원을 저장할 때,") + @Nested + class Save { + + @DisplayName("정상적으로 저장되고, ID가 부여된다.") + @Test + void savesMember_andAssignsId() { + // arrange + Member member = createMember("testuser1", "test@example.com"); + + // act + Member saved = memberRepository.save(member); + + // assert + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(saved.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(saved.getName()).isEqualTo("홍길동"), + () -> assertThat(saved.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(saved.getEmail()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("로그인 ID 중복을 확인할 때,") + @Nested + class ExistsByLoginId { + + @DisplayName("존재하는 loginId이면, true를 반환한다.") + @Test + void returnsTrue_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByLoginId("testuser1"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 loginId이면, false를 반환한다.") + @Test + void returnsFalse_whenLoginIdDoesNotExist() { + // act + boolean result = memberRepository.existsByLoginId("nonexistent"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("로그인 ID로 회원을 조회할 때,") + @Nested + class FindByLoginId { + + @DisplayName("존재하는 loginId이면, 회원을 반환한다.") + @Test + void returnsMember_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + Optional result = memberRepository.findByLoginId("testuser1"); + + // assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.get().getName()).isEqualTo("홍길동"), + () -> assertThat(result.get().getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(result.get().getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId이면, 빈 Optional을 반환한다.") + @Test + void returnsEmpty_whenLoginIdDoesNotExist() { + // act + Optional result = memberRepository.findByLoginId("nonexistent"); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("이메일 중복을 확인할 때,") + @Nested + class ExistsByEmail { + + @DisplayName("존재하는 email이면, true를 반환한다.") + @Test + void returnsTrue_whenEmailExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByEmail("test@example.com"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 email이면, false를 반환한다.") + @Test + void returnsFalse_whenEmailDoesNotExist() { + // act + boolean result = memberRepository.existsByEmail("nonexistent@example.com"); + + // assert + assertThat(result).isFalse(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..73a9f47d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,406 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +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) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 201 Created와 회원 정보를 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenFieldMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", null + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일이 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", null, "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일 형식이 잘못되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayFormatInvalid() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "19950315", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenDuplicateLoginId() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Other1234!", "김철수", "1990-01-01", "other@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/members/me") + @Nested + class GetMyInfo { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return new HttpEntity<>(headers); + } + + @DisplayName("올바른 인증 정보로 조회하면, 200 OK와 회원 정보를 반환한다.") + @Test + void returnsOk_whenValidCredentials() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Test1234!"), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthday()).isEqualTo("1995-03-15"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 조회하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("nonexistent", "Test1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Wrong1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PATCH /api/v1/members/me/password") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createRequest( + String loginId, String headerPassword, String currentPassword, String newPassword + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", headerPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(new MemberV1Dto.UpdatePasswordRequest(currentPassword, newPassword), headers); + } + + @DisplayName("올바른 인증 정보와 유효한 새 비밀번호로 변경하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("비밀번호 변경 후, 새 비밀번호로 내 정보 조회가 가능하다.") + @Test + void canLoginWithNewPassword_afterPasswordUpdate() { + // arrange + saveMember("testuser1", "Test1234!"); + testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + new ParameterizedTypeReference>() {} + ); + + // act + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser1"); + headers.set("X-Loopers-LoginPw", "NewPass123!"); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(headers), responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1") + ); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("nonexistent", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenCurrentPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Wrong1234!", "Wrong1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("헤더 비밀번호와 Body currentPassword가 다르면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderPasswordMismatchesBody() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Different1!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "Test1234!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordTooShort() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "New12!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>( + new MemberV1Dto.UpdatePasswordRequest("Test1234!", "NewPass123!"), headers + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, request, responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 000000000..b00d6462b --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,27 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "홍길동", + "birthday": "1995-03-15", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/members/me +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/members/me/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass123!" +} \ No newline at end of file From 4ae20be0ebb25bf6de1a8cde7614c5320721996e Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:09:29 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20Address=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Address Entity, Repository, Service 계층 구현 - AddressFacade 유즈케이스 오케스트레이션 - 배송지 CRUD API (생성, 조회, 수정, 삭제) - 기본 배송지 설정 기능 - 회원별 배송지 목록 조회 - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../application/address/AddressCommand.java | 22 + .../application/address/AddressFacade.java | 71 ++ .../application/address/AddressInfo.java | 26 + .../com/loopers/domain/address/Address.java | 92 +++ .../domain/address/AddressRepository.java | 17 + .../domain/address/AddressService.java | 91 +++ .../infrastructure/address/AddressEntity.java | 106 +++ .../address/AddressJpaRepository.java | 13 + .../address/AddressRepositoryImpl.java | 61 ++ .../api/address/AddressV1ApiSpec.java | 62 ++ .../api/address/AddressV1Controller.java | 88 +++ .../interfaces/api/address/AddressV1Dto.java | 60 ++ .../address/AddressFacadeTest.java | 170 +++++ .../domain/address/AddressServiceTest.java | 383 +++++++++++ .../loopers/domain/address/AddressTest.java | 221 +++++++ .../api/address/AddressV1ApiE2ETest.java | 621 ++++++++++++++++++ http/address-v1.http | 56 ++ 17 files changed, 2160 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/address/AddressCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/address/AddressFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/address/AddressV1ApiE2ETest.java create mode 100644 http/address-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressCommand.java new file mode 100644 index 000000000..a30defd5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressCommand.java @@ -0,0 +1,22 @@ +package com.loopers.application.address; + +public class AddressCommand { + + public record Create( + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail + ) { + } + + public record Update( + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java new file mode 100644 index 000000000..5dbd84f3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java @@ -0,0 +1,71 @@ +package com.loopers.application.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AddressFacade { + + private final AddressService addressService; + private final MemberService memberService; + + @Transactional(readOnly = true) + public List getAddresses(String loginId, String password) { + Member member = memberService.authenticate(loginId, password); + return addressService.getAddresses(member.getId()) + .stream() + .map(AddressInfo::from) + .toList(); + } + + @Transactional + public AddressInfo register(String loginId, String password, AddressCommand.Create command) { + Member member = memberService.authenticate(loginId, password); + Address address = new Address( + member.getId(), + command.recipientName(), + command.phone(), + command.zipCode(), + command.address(), + command.addressDetail() + ); + Address savedAddress = addressService.register(address); + return AddressInfo.from(savedAddress); + } + + @Transactional + public AddressInfo update(String loginId, String password, Long addressId, AddressCommand.Update command) { + Member member = memberService.authenticate(loginId, password); + Address updatedAddress = addressService.update( + member.getId(), + addressId, + command.recipientName(), + command.phone(), + command.zipCode(), + command.address(), + command.addressDetail() + ); + return AddressInfo.from(updatedAddress); + } + + @Transactional + public void delete(String loginId, String password, Long addressId) { + Member member = memberService.authenticate(loginId, password); + addressService.delete(member.getId(), addressId); + } + + @Transactional + public AddressInfo setDefault(String loginId, String password, Long addressId) { + Member member = memberService.authenticate(loginId, password); + Address updatedAddress = addressService.setDefault(member.getId(), addressId); + return AddressInfo.from(updatedAddress); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java new file mode 100644 index 000000000..5cbaa8ba5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.address; + +import com.loopers.domain.address.Address; + +public record AddressInfo( + Long id, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + boolean isDefault +) { + + public static AddressInfo from(Address address) { + return new AddressInfo( + address.getId(), + address.getRecipientName(), + address.getPhone(), + address.getZipCode(), + address.getAddress(), + address.getAddressDetail(), + address.isDefault() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java new file mode 100644 index 000000000..e70044073 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java @@ -0,0 +1,92 @@ +package com.loopers.domain.address; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +@Getter +public class Address { + + private Long id; + private Long memberId; + private String recipientName; + private String phone; + private String zipCode; + private String address; + private String addressDetail; + private boolean isDefault; + + public Address(Long memberId, String recipientName, String phone, String zipCode, String address, String addressDetail) { + validateMemberId(memberId); + validateRecipientName(recipientName); + validatePhone(phone); + validateAddress(address); + + this.memberId = memberId; + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + this.isDefault = false; + } + + public Address(Long id, Long memberId, String recipientName, String phone, String zipCode, String address, String addressDetail, boolean isDefault) { + this.id = id; + this.memberId = memberId; + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + this.isDefault = isDefault; + } + + public void update(String recipientName, String phone, String zipCode, String address, String addressDetail) { + validateRecipientName(recipientName); + validatePhone(phone); + validateAddress(address); + + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + } + + public void setAsDefault() { + this.isDefault = true; + } + + public void unsetDefault() { + this.isDefault = false; + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + } + + private void validateRecipientName(String recipientName) { + if (recipientName == null || recipientName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "수령인 이름은 필수입니다."); + } + } + + private void validatePhone(String phone) { + if (phone == null || phone.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "전화번호는 필수입니다."); + } + } + + private void validateAddress(String address) { + if (address == null || address.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주소는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java new file mode 100644 index 000000000..1e729ad40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.address; + +import java.util.List; +import java.util.Optional; + +public interface AddressRepository { + + List
findByMemberId(Long memberId); + + Optional
findById(Long id); + + Optional
findDefaultByMemberId(Long memberId); + + Address save(Address address); + + void delete(Address address); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java new file mode 100644 index 000000000..d1805a311 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java @@ -0,0 +1,91 @@ +package com.loopers.domain.address; + +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AddressService { + + private static final int MAX_ADDRESS_COUNT = 5; + + private final AddressRepository addressRepository; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public List
getAddresses(Long memberId) { + return addressRepository.findByMemberId(memberId); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Address register(Address address) { + List
existingAddresses = addressRepository.findByMemberId(address.getMemberId()); + + if (existingAddresses.size() >= MAX_ADDRESS_COUNT) { + throw new CoreException(ErrorType.BAD_REQUEST, "배송지는 최대 " + MAX_ADDRESS_COUNT + "개까지 등록할 수 있습니다."); + } + + if (existingAddresses.isEmpty()) { + address.setAsDefault(); + } + + return addressRepository.save(address); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Address update(Long memberId, Long addressId, String recipientName, String phone, String zipCode, String address, String addressDetail) { + Address existingAddress = getAddressOrThrow(addressId); + validateOwnership(memberId, existingAddress); + + existingAddress.update(recipientName, phone, zipCode, address, addressDetail); + return addressRepository.save(existingAddress); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void delete(Long memberId, Long addressId) { + Address address = getAddressOrThrow(addressId); + validateOwnership(memberId, address); + + addressRepository.delete(address); + + List
remainingAddresses = addressRepository.findByMemberId(memberId); + if (remainingAddresses.size() == 1) { + Address remainingAddress = remainingAddresses.get(0); + if (!remainingAddress.isDefault()) { + remainingAddress.setAsDefault(); + addressRepository.save(remainingAddress); + } + } + } + + @Transactional(propagation = Propagation.REQUIRED) + public Address setDefault(Long memberId, Long addressId) { + Address address = getAddressOrThrow(addressId); + validateOwnership(memberId, address); + + addressRepository.findDefaultByMemberId(memberId) + .ifPresent(existingDefault -> { + existingDefault.unsetDefault(); + addressRepository.save(existingDefault); + }); + + address.setAsDefault(); + return addressRepository.save(address); + } + + private Address getAddressOrThrow(Long addressId) { + return addressRepository.findById(addressId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "배송지를 찾을 수 없습니다.")); + } + + private void validateOwnership(Long memberId, Address address) { + if (!address.isOwnedBy(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "해당 배송지에 대한 권한이 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressEntity.java new file mode 100644 index 000000000..617a53c6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressEntity.java @@ -0,0 +1,106 @@ +package com.loopers.infrastructure.address; + +import com.loopers.domain.address.Address; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "member_addresses", + indexes = { + @Index(name = "idx_member_addresses_member_id", columnList = "member_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AddressEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "recipient_name", nullable = false, length = 50) + private String recipientName; + + @Column(name = "phone", nullable = false, length = 20) + private String phone; + + @Column(name = "zip_code", length = 10) + private String zipCode; + + @Column(name = "address", nullable = false, length = 255) + private String address; + + @Column(name = "address_detail", length = 255) + private String addressDetail; + + @Column(name = "is_default", nullable = false) + private boolean isDefault = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public static AddressEntity from(Address address) { + AddressEntity entity = new AddressEntity(); + entity.memberId = address.getMemberId(); + entity.recipientName = address.getRecipientName(); + entity.phone = address.getPhone(); + entity.zipCode = address.getZipCode(); + entity.address = address.getAddress(); + entity.addressDetail = address.getAddressDetail(); + entity.isDefault = address.isDefault(); + return entity; + } + + public Address toDomain() { + return new Address( + id, + memberId, + recipientName, + phone, + zipCode, + address, + addressDetail, + isDefault + ); + } + + public void update(String recipientName, String phone, String zipCode, String address, String addressDetail, boolean isDefault) { + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + this.isDefault = isDefault; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java new file mode 100644 index 000000000..02c7a9862 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.address; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AddressJpaRepository extends JpaRepository { + + List findByMemberIdOrderByIsDefaultDescIdAsc(Long memberId); + + Optional findByMemberIdAndIsDefaultTrue(Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java new file mode 100644 index 000000000..41aeafee8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class AddressRepositoryImpl implements AddressRepository { + + private final AddressJpaRepository addressJpaRepository; + + @Override + public List
findByMemberId(Long memberId) { + return addressJpaRepository.findByMemberIdOrderByIsDefaultDescIdAsc(memberId) + .stream() + .map(AddressEntity::toDomain) + .toList(); + } + + @Override + public Optional
findById(Long id) { + return addressJpaRepository.findById(id) + .map(AddressEntity::toDomain); + } + + @Override + public Optional
findDefaultByMemberId(Long memberId) { + return addressJpaRepository.findByMemberIdAndIsDefaultTrue(memberId) + .map(AddressEntity::toDomain); + } + + @Override + public Address save(Address address) { + AddressEntity entity; + if (address.getId() != null) { + entity = addressJpaRepository.findById(address.getId()) + .orElseGet(() -> AddressEntity.from(address)); + entity.update( + address.getRecipientName(), + address.getPhone(), + address.getZipCode(), + address.getAddress(), + address.getAddressDetail(), + address.isDefault() + ); + } else { + entity = AddressEntity.from(address); + } + return addressJpaRepository.save(entity).toDomain(); + } + + @Override + public void delete(Address address) { + addressJpaRepository.deleteById(address.getId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1ApiSpec.java new file mode 100644 index 000000000..b0de80fac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1ApiSpec.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Address V1 API", description = "배송지 관리 API 입니다.") +public interface AddressV1ApiSpec { + + @Operation( + summary = "배송지 목록 조회", + description = "회원의 배송지 목록을 조회합니다. 기본 배송지가 먼저 표시됩니다." + ) + ApiResponse> getAddresses( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password + ); + + @Operation( + summary = "배송지 등록", + description = "새로운 배송지를 등록합니다. 첫 번째 배송지는 자동으로 기본 배송지로 설정됩니다. 최대 5개까지 등록 가능합니다." + ) + ApiResponse registerAddress( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + AddressV1Dto.RegisterRequest request + ); + + @Operation( + summary = "배송지 수정", + description = "기존 배송지 정보를 수정합니다." + ) + ApiResponse updateAddress( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "배송지 ID", required = true) Long addressId, + AddressV1Dto.UpdateRequest request + ); + + @Operation( + summary = "배송지 삭제", + description = "배송지를 삭제합니다." + ) + ApiResponse deleteAddress( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "배송지 ID", required = true) Long addressId + ); + + @Operation( + summary = "기본 배송지 설정", + description = "해당 배송지를 기본 배송지로 설정합니다. 기존 기본 배송지는 자동으로 해제됩니다." + ) + ApiResponse setDefaultAddress( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "배송지 ID", required = true) Long addressId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java new file mode 100644 index 000000000..c68c182f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java @@ -0,0 +1,88 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.application.address.AddressFacade; +import com.loopers.application.address.AddressInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/addresses") +public class AddressV1Controller implements AddressV1ApiSpec { + + private final AddressFacade addressFacade; + + @GetMapping + @Override + public ApiResponse> getAddresses( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + List addresses = addressFacade.getAddresses(loginId, password); + List response = addresses.stream() + .map(AddressV1Dto.AddressResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse registerAddress( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @Valid @RequestBody AddressV1Dto.RegisterRequest request + ) { + AddressInfo info = addressFacade.register(loginId, password, request.toCommand()); + return ApiResponse.success(AddressV1Dto.AddressResponse.from(info)); + } + + @PutMapping("/{addressId}") + @Override + public ApiResponse updateAddress( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long addressId, + @Valid @RequestBody AddressV1Dto.UpdateRequest request + ) { + AddressInfo info = addressFacade.update(loginId, password, addressId, request.toCommand()); + return ApiResponse.success(AddressV1Dto.AddressResponse.from(info)); + } + + @DeleteMapping("/{addressId}") + @Override + public ApiResponse deleteAddress( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long addressId + ) { + addressFacade.delete(loginId, password, addressId); + return ApiResponse.success(null); + } + + @PatchMapping("/{addressId}/default") + @Override + public ApiResponse setDefaultAddress( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long addressId + ) { + AddressInfo info = addressFacade.setDefault(loginId, password, addressId); + return ApiResponse.success(AddressV1Dto.AddressResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java new file mode 100644 index 000000000..9f9bfde54 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.application.address.AddressCommand; +import com.loopers.application.address.AddressInfo; +import jakarta.validation.constraints.NotBlank; + +public class AddressV1Dto { + + public record RegisterRequest( + @NotBlank(message = "수령인 이름은 필수입니다.") + String recipientName, + @NotBlank(message = "전화번호는 필수입니다.") + String phone, + String zipCode, + @NotBlank(message = "주소는 필수입니다.") + String address, + String addressDetail + ) { + public AddressCommand.Create toCommand() { + return new AddressCommand.Create(recipientName, phone, zipCode, address, addressDetail); + } + } + + public record UpdateRequest( + @NotBlank(message = "수령인 이름은 필수입니다.") + String recipientName, + @NotBlank(message = "전화번호는 필수입니다.") + String phone, + String zipCode, + @NotBlank(message = "주소는 필수입니다.") + String address, + String addressDetail + ) { + public AddressCommand.Update toCommand() { + return new AddressCommand.Update(recipientName, phone, zipCode, address, addressDetail); + } + } + + public record AddressResponse( + Long id, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + boolean isDefault + ) { + public static AddressResponse from(AddressInfo info) { + return new AddressResponse( + info.id(), + info.recipientName(), + info.phone(), + info.zipCode(), + info.address(), + info.addressDetail(), + info.isDefault() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/address/AddressFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/address/AddressFacadeTest.java new file mode 100644 index 000000000..54504d125 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/address/AddressFacadeTest.java @@ -0,0 +1,170 @@ +package com.loopers.application.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AddressFacadeTest { + + @Mock + private AddressService addressService; + + @Mock + private MemberService memberService; + + @InjectMocks + private AddressFacade addressFacade; + + private static final String LOGIN_ID = "testuser"; + private static final String PASSWORD = "Password123!"; + private static final Long MEMBER_ID = 1L; + + @DisplayName("배송지 목록 조회") + @Nested + class GetAddresses { + + @Test + @DisplayName("인증 후 회원의 배송지 목록을 조회한다") + void returnsAddresses_afterAuthentication() { + // arrange + Member member = createMember(); + Address address1 = createAddress(1L, MEMBER_ID, true); + Address address2 = createAddress(2L, MEMBER_ID, false); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.getAddresses(MEMBER_ID)).willReturn(List.of(address1, address2)); + + // act + List result = addressFacade.getAddresses(LOGIN_ID, PASSWORD); + + // assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).id()).isEqualTo(1L), + () -> assertThat(result.get(1).id()).isEqualTo(2L) + ); + } + } + + @DisplayName("배송지 등록") + @Nested + class RegisterAddress { + + @Test + @DisplayName("인증 후 배송지를 등록한다") + void registersAddress_afterAuthentication() { + // arrange + Member member = createMember(); + AddressCommand.Create command = new AddressCommand.Create( + "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호" + ); + Address savedAddress = createAddress(1L, MEMBER_ID, true); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.register(any(Address.class))).willReturn(savedAddress); + + // act + AddressInfo result = addressFacade.register(LOGIN_ID, PASSWORD, command); + + // assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.recipientName()).isEqualTo("홍길동"), + () -> assertThat(result.isDefault()).isTrue() + ); + } + } + + @DisplayName("배송지 수정") + @Nested + class UpdateAddress { + + @Test + @DisplayName("인증 후 배송지를 수정한다") + void updatesAddress_afterAuthentication() { + // arrange + Member member = createMember(); + AddressCommand.Update command = new AddressCommand.Update( + "김철수", "010-9999-8888", "12345", "부산시", "201호" + ); + Address updatedAddress = new Address(1L, MEMBER_ID, "김철수", "010-9999-8888", "12345", "부산시", "201호", false); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.update(eq(MEMBER_ID), eq(1L), eq("김철수"), eq("010-9999-8888"), eq("12345"), eq("부산시"), eq("201호"))) + .willReturn(updatedAddress); + + // act + AddressInfo result = addressFacade.update(LOGIN_ID, PASSWORD, 1L, command); + + // assert + assertAll( + () -> assertThat(result.recipientName()).isEqualTo("김철수"), + () -> assertThat(result.phone()).isEqualTo("010-9999-8888") + ); + } + } + + @DisplayName("배송지 삭제") + @Nested + class DeleteAddress { + + @Test + @DisplayName("인증 후 배송지를 삭제한다") + void deletesAddress_afterAuthentication() { + // arrange + Member member = createMember(); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + + // act + addressFacade.delete(LOGIN_ID, PASSWORD, 1L); + + // assert + verify(addressService).delete(MEMBER_ID, 1L); + } + } + + @DisplayName("기본 배송지 설정") + @Nested + class SetDefaultAddress { + + @Test + @DisplayName("인증 후 기본 배송지를 설정한다") + void setsDefaultAddress_afterAuthentication() { + // arrange + Member member = createMember(); + Address updatedAddress = createAddress(1L, MEMBER_ID, true); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.setDefault(MEMBER_ID, 1L)).willReturn(updatedAddress); + + // act + AddressInfo result = addressFacade.setDefault(LOGIN_ID, PASSWORD, 1L); + + // assert + assertThat(result.isDefault()).isTrue(); + } + } + + private Member createMember() { + return new Member(MEMBER_ID, LOGIN_ID, "encodedPassword", "테스트", LocalDate.of(1990, 1, 1), "test@example.com"); + } + + private Address createAddress(Long id, Long memberId, boolean isDefault) { + return new Address(id, memberId, "홍길동", "010-1234-5678", "06234", "서울시", "101호", isDefault); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java new file mode 100644 index 000000000..f7779bee1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java @@ -0,0 +1,383 @@ +package com.loopers.domain.address; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AddressServiceTest { + + @Mock + private AddressRepository addressRepository; + + @InjectMocks + private AddressService addressService; + + private static final Long MEMBER_ID = 1L; + + @DisplayName("배송지 목록 조회") + @Nested + class GetAddresses { + + @Test + @DisplayName("회원의 배송지 목록을 조회한다") + void returnsAddresses_forMember() { + // arrange + Address address1 = createAddress(1L, MEMBER_ID, true); + Address address2 = createAddress(2L, MEMBER_ID, false); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(address1, address2)); + + // act + List
result = addressService.getAddresses(MEMBER_ID); + + // assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).getId()).isEqualTo(1L), + () -> assertThat(result.get(1).getId()).isEqualTo(2L) + ); + } + + @Test + @DisplayName("배송지가 없으면 빈 목록을 반환한다") + void returnsEmptyList_whenNoAddresses() { + // arrange + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(Collections.emptyList()); + + // act + List
result = addressService.getAddresses(MEMBER_ID); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("배송지 등록") + @Nested + class RegisterAddress { + + @Test + @DisplayName("첫 번째 배송지는 자동으로 기본 배송지로 설정된다") + void setsAsDefault_whenFirstAddress() { + // arrange + Address newAddress = new Address(MEMBER_ID, "홍길동", "010-1234-5678", null, "서울시", null); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(Collections.emptyList()); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Address result = addressService.register(newAddress); + + // assert + assertThat(result.isDefault()).isTrue(); + } + + @Test + @DisplayName("두 번째 이후 배송지는 기본 배송지로 설정되지 않는다") + void doesNotSetAsDefault_whenNotFirstAddress() { + // arrange + Address existingAddress = createAddress(1L, MEMBER_ID, true); + Address newAddress = new Address(MEMBER_ID, "홍길동", "010-1234-5678", null, "서울시", null); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(existingAddress)); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Address result = addressService.register(newAddress); + + // assert + assertThat(result.isDefault()).isFalse(); + } + + @Test + @DisplayName("배송지 개수가 최대치(5개)를 초과하면 예외가 발생한다") + void throwsException_whenExceedsMaxAddresses() { + // arrange + List
existingAddresses = List.of( + createAddress(1L, MEMBER_ID, true), + createAddress(2L, MEMBER_ID, false), + createAddress(3L, MEMBER_ID, false), + createAddress(4L, MEMBER_ID, false), + createAddress(5L, MEMBER_ID, false) + ); + Address newAddress = new Address(MEMBER_ID, "홍길동", "010-1234-5678", null, "서울시", null); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(existingAddresses); + + // act & assert + assertThatThrownBy(() -> addressService.register(newAddress)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("배송지 수정") + @Nested + class UpdateAddress { + + @Test + @DisplayName("배송지 정보를 수정할 수 있다") + void updatesAddress() { + // arrange + Address address = createAddress(1L, MEMBER_ID, false); + given(addressRepository.findById(1L)).willReturn(Optional.of(address)); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Address result = addressService.update(MEMBER_ID, 1L, "김철수", "010-9999-8888", "12345", "부산시", "201호"); + + // assert + assertAll( + () -> assertThat(result.getRecipientName()).isEqualTo("김철수"), + () -> assertThat(result.getPhone()).isEqualTo("010-9999-8888"), + () -> assertThat(result.getZipCode()).isEqualTo("12345"), + () -> assertThat(result.getAddress()).isEqualTo("부산시"), + () -> assertThat(result.getAddressDetail()).isEqualTo("201호") + ); + } + + @Test + @DisplayName("존재하지 않는 배송지를 수정하면 예외가 발생한다") + void throwsException_whenAddressNotFound() { + // arrange + given(addressRepository.findById(999L)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> addressService.update(MEMBER_ID, 999L, "홍길동", "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 수정하면 예외가 발생한다") + void throwsException_whenNotOwner() { + // arrange + Address address = createAddress(1L, 2L, false); // 다른 회원의 배송지 + given(addressRepository.findById(1L)).willReturn(Optional.of(address)); + + // act & assert + assertThatThrownBy(() -> addressService.update(MEMBER_ID, 1L, "홍길동", "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + } + } + + @DisplayName("배송지 삭제") + @Nested + class DeleteAddress { + + @Test + @DisplayName("배송지를 삭제할 수 있다") + void deletesAddress() { + // arrange + Address address = createAddress(1L, MEMBER_ID, false); + given(addressRepository.findById(1L)).willReturn(Optional.of(address)); + + // act + addressService.delete(MEMBER_ID, 1L); + + // assert + verify(addressRepository).delete(address); + } + + @Test + @DisplayName("존재하지 않는 배송지를 삭제하면 예외가 발생한다") + void throwsException_whenAddressNotFound() { + // arrange + given(addressRepository.findById(999L)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> addressService.delete(MEMBER_ID, 999L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 삭제하면 예외가 발생한다") + void throwsException_whenNotOwner() { + // arrange + Address address = createAddress(1L, 2L, false); // 다른 회원의 배송지 + given(addressRepository.findById(1L)).willReturn(Optional.of(address)); + + // act & assert + assertThatThrownBy(() -> addressService.delete(MEMBER_ID, 1L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + + verify(addressRepository, never()).delete(any()); + } + + @Test + @DisplayName("삭제 후 남은 배송지가 1개이면 자동으로 기본 배송지로 설정된다") + void setsAsDefault_whenOneAddressRemains() { + // arrange + Address defaultAddress = createAddress(1L, MEMBER_ID, true); + Address remainingAddress = createAddress(2L, MEMBER_ID, false); + given(addressRepository.findById(1L)).willReturn(Optional.of(defaultAddress)); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(remainingAddress)); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + addressService.delete(MEMBER_ID, 1L); + + // assert + assertAll( + () -> verify(addressRepository).delete(defaultAddress), + () -> verify(addressRepository).save(remainingAddress), + () -> assertThat(remainingAddress.isDefault()).isTrue() + ); + } + + @Test + @DisplayName("삭제 후 남은 배송지가 2개 이상이면 기본 배송지를 변경하지 않는다") + void doesNotChangeDefault_whenMultipleAddressesRemain() { + // arrange + Address addressToDelete = createAddress(1L, MEMBER_ID, false); + Address defaultAddress = createAddress(2L, MEMBER_ID, true); + Address anotherAddress = createAddress(3L, MEMBER_ID, false); + given(addressRepository.findById(1L)).willReturn(Optional.of(addressToDelete)); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(defaultAddress, anotherAddress)); + + // act + addressService.delete(MEMBER_ID, 1L); + + // assert + assertAll( + () -> verify(addressRepository).delete(addressToDelete), + () -> verify(addressRepository, never()).save(any()), + () -> assertThat(defaultAddress.isDefault()).isTrue(), + () -> assertThat(anotherAddress.isDefault()).isFalse() + ); + } + + @Test + @DisplayName("삭제 후 남은 배송지가 없으면 아무 동작도 하지 않는다") + void doesNothing_whenNoAddressesRemain() { + // arrange + Address onlyAddress = createAddress(1L, MEMBER_ID, true); + given(addressRepository.findById(1L)).willReturn(Optional.of(onlyAddress)); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(Collections.emptyList()); + + // act + addressService.delete(MEMBER_ID, 1L); + + // assert + assertAll( + () -> verify(addressRepository).delete(onlyAddress), + () -> verify(addressRepository, never()).save(any()) + ); + } + + @Test + @DisplayName("삭제 후 남은 배송지가 1개이고 이미 기본 배송지이면 변경하지 않는다") + void doesNotChange_whenRemainingAddressIsAlreadyDefault() { + // arrange + Address addressToDelete = createAddress(1L, MEMBER_ID, false); + Address remainingDefaultAddress = createAddress(2L, MEMBER_ID, true); + given(addressRepository.findById(1L)).willReturn(Optional.of(addressToDelete)); + given(addressRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(remainingDefaultAddress)); + + // act + addressService.delete(MEMBER_ID, 1L); + + // assert + assertAll( + () -> verify(addressRepository).delete(addressToDelete), + () -> verify(addressRepository, never()).save(any()) + ); + } + } + + @DisplayName("기본 배송지 설정") + @Nested + class SetDefaultAddress { + + @Test + @DisplayName("기본 배송지로 설정하면 기존 기본 배송지가 해제된다") + void unsetsExistingDefault_whenSettingNewDefault() { + // arrange + Address existingDefault = createAddress(1L, MEMBER_ID, true); + Address newDefault = createAddress(2L, MEMBER_ID, false); + given(addressRepository.findById(2L)).willReturn(Optional.of(newDefault)); + given(addressRepository.findDefaultByMemberId(MEMBER_ID)).willReturn(Optional.of(existingDefault)); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Address result = addressService.setDefault(MEMBER_ID, 2L); + + // assert + assertAll( + () -> assertThat(result.isDefault()).isTrue(), + () -> assertThat(existingDefault.isDefault()).isFalse() + ); + } + + @Test + @DisplayName("기존 기본 배송지가 없어도 새 기본 배송지를 설정할 수 있다") + void setsNewDefault_whenNoExistingDefault() { + // arrange + Address newDefault = createAddress(2L, MEMBER_ID, false); + given(addressRepository.findById(2L)).willReturn(Optional.of(newDefault)); + given(addressRepository.findDefaultByMemberId(MEMBER_ID)).willReturn(Optional.empty()); + given(addressRepository.save(any(Address.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Address result = addressService.setDefault(MEMBER_ID, 2L); + + // assert + assertThat(result.isDefault()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 배송지를 기본으로 설정하면 예외가 발생한다") + void throwsException_whenAddressNotFound() { + // arrange + given(addressRepository.findById(999L)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> addressService.setDefault(MEMBER_ID, 999L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 기본으로 설정하면 예외가 발생한다") + void throwsException_whenNotOwner() { + // arrange + Address address = createAddress(1L, 2L, false); // 다른 회원의 배송지 + given(addressRepository.findById(1L)).willReturn(Optional.of(address)); + + // act & assert + assertThatThrownBy(() -> addressService.setDefault(MEMBER_ID, 1L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + } + } + + private Address createAddress(Long id, Long memberId, boolean isDefault) { + return new Address(id, memberId, "홍길동", "010-1234-5678", "06234", "서울시", "101호", isDefault); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java new file mode 100644 index 000000000..fef8c6072 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java @@ -0,0 +1,221 @@ +package com.loopers.domain.address; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class AddressTest { + + @DisplayName("Address 생성") + @Nested + class Create { + + @Test + @DisplayName("필수 필드로 Address를 생성할 수 있다") + void createsAddress_withRequiredFields() { + // arrange + Long memberId = 1L; + String recipientName = "홍길동"; + String phone = "010-1234-5678"; + String address = "서울시 강남구 테헤란로 123"; + + // act + Address result = new Address(memberId, recipientName, phone, null, address, null); + + // assert + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(memberId), + () -> assertThat(result.getRecipientName()).isEqualTo(recipientName), + () -> assertThat(result.getPhone()).isEqualTo(phone), + () -> assertThat(result.getAddress()).isEqualTo(address), + () -> assertThat(result.isDefault()).isFalse() + ); + } + + @Test + @DisplayName("모든 필드로 Address를 생성할 수 있다") + void createsAddress_withAllFields() { + // arrange + Long memberId = 1L; + String recipientName = "홍길동"; + String phone = "010-1234-5678"; + String zipCode = "06234"; + String address = "서울시 강남구 테헤란로 123"; + String addressDetail = "5층 501호"; + + // act + Address result = new Address(memberId, recipientName, phone, zipCode, address, addressDetail); + + // assert + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(memberId), + () -> assertThat(result.getRecipientName()).isEqualTo(recipientName), + () -> assertThat(result.getPhone()).isEqualTo(phone), + () -> assertThat(result.getZipCode()).isEqualTo(zipCode), + () -> assertThat(result.getAddress()).isEqualTo(address), + () -> assertThat(result.getAddressDetail()).isEqualTo(addressDetail), + () -> assertThat(result.isDefault()).isFalse() + ); + } + + @Test + @DisplayName("memberId가 null이면 예외가 발생한다") + void throwsException_whenMemberIdIsNull() { + // act & assert + assertThatThrownBy(() -> new Address(null, "홍길동", "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("수령인 이름이 null이면 예외가 발생한다") + void throwsException_whenRecipientNameIsNull() { + // act & assert + assertThatThrownBy(() -> new Address(1L, null, "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("수령인 이름이 빈 문자열이면 예외가 발생한다") + void throwsException_whenRecipientNameIsEmpty() { + // act & assert + assertThatThrownBy(() -> new Address(1L, " ", "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("전화번호가 null이면 예외가 발생한다") + void throwsException_whenPhoneIsNull() { + // act & assert + assertThatThrownBy(() -> new Address(1L, "홍길동", null, null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("주소가 null이면 예외가 발생한다") + void throwsException_whenAddressIsNull() { + // act & assert + assertThatThrownBy(() -> new Address(1L, "홍길동", "010-1234-5678", null, null, null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("주소가 빈 문자열이면 예외가 발생한다") + void throwsException_whenAddressIsEmpty() { + // act & assert + assertThatThrownBy(() -> new Address(1L, "홍길동", "010-1234-5678", null, " ", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Address 수정") + @Nested + class Update { + + @Test + @DisplayName("Address 정보를 수정할 수 있다") + void updatesAddress() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호"); + + // act + address.update("김철수", "010-9999-8888", "12345", "부산시 해운대구", "201호"); + + // assert + assertAll( + () -> assertThat(address.getRecipientName()).isEqualTo("김철수"), + () -> assertThat(address.getPhone()).isEqualTo("010-9999-8888"), + () -> assertThat(address.getZipCode()).isEqualTo("12345"), + () -> assertThat(address.getAddress()).isEqualTo("부산시 해운대구"), + () -> assertThat(address.getAddressDetail()).isEqualTo("201호") + ); + } + + @Test + @DisplayName("수정 시 수령인 이름이 null이면 예외가 발생한다") + void throwsException_whenUpdatingWithNullRecipientName() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", null, "서울시", null); + + // act & assert + assertThatThrownBy(() -> address.update(null, "010-1234-5678", null, "서울시", null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("기본 배송지 설정") + @Nested + class SetDefault { + + @Test + @DisplayName("기본 배송지로 설정할 수 있다") + void setsAsDefault() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", null, "서울시", null); + + // act + address.setAsDefault(); + + // assert + assertThat(address.isDefault()).isTrue(); + } + + @Test + @DisplayName("기본 배송지를 해제할 수 있다") + void unsetsAsDefault() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", null, "서울시", null); + address.setAsDefault(); + + // act + address.unsetDefault(); + + // assert + assertThat(address.isDefault()).isFalse(); + } + } + + @DisplayName("소유권 검증") + @Nested + class Ownership { + + @Test + @DisplayName("소유자가 일치하면 true를 반환한다") + void returnsTrue_whenOwnerMatches() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", null, "서울시", null); + + // act & assert + assertThat(address.isOwnedBy(1L)).isTrue(); + } + + @Test + @DisplayName("소유자가 일치하지 않으면 false를 반환한다") + void returnsFalse_whenOwnerDoesNotMatch() { + // arrange + Address address = new Address(1L, "홍길동", "010-1234-5678", null, "서울시", null); + + // act & assert + assertThat(address.isOwnedBy(2L)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/address/AddressV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/address/AddressV1ApiE2ETest.java new file mode 100644 index 000000000..679108e16 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/address/AddressV1ApiE2ETest.java @@ -0,0 +1,621 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +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) +class AddressV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AddressV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/addresses - 배송지 목록 조회") + @Nested + class GetAddresses { + + private Member member; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + } + + @Test + @DisplayName("배송지 목록을 조회하면 200 OK를 반환한다") + void returnsOk_whenGetAddresses() { + // arrange + registerAddress(member.getLoginId(), "Password123!", "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호"); + registerAddress(member.getLoginId(), "Password123!", "김철수", "010-9999-8888", "12345", "부산시 해운대구", "201호"); + + // act + ResponseEntity>> response = getAddresses( + member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2) + ); + } + + @Test + @DisplayName("배송지가 없으면 빈 목록을 반환한다") + void returnsEmptyList_whenNoAddresses() { + // act + ResponseEntity>> response = getAddresses( + member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + + @Test + @DisplayName("인증 실패 시 401 Unauthorized를 반환한다") + void returnsUnauthorized_whenAuthenticationFails() { + // act + ResponseEntity> response = getAddressesWithError( + member.getLoginId(), "WrongPassword!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + private ResponseEntity>> getAddresses(String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getAddressesWithError(String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("POST /api/v1/addresses - 배송지 등록") + @Nested + class RegisterAddress { + + private Member member; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + } + + @Test + @DisplayName("배송지를 등록하면 201 Created를 반환한다") + void returnsCreated_whenRegisterAddress() { + // act + ResponseEntity> response = registerAddressWithResponse( + member.getLoginId(), "Password123!", + "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().recipientName()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().isDefault()).isTrue() + ); + } + + @Test + @DisplayName("첫 번째 배송지는 자동으로 기본 배송지로 설정된다") + void setsAsDefault_whenFirstAddress() { + // act + ResponseEntity> response = registerAddressWithResponse( + member.getLoginId(), "Password123!", + "홍길동", "010-1234-5678", null, "서울시", null + ); + + // assert + assertThat(response.getBody().data().isDefault()).isTrue(); + } + + @Test + @DisplayName("두 번째 이후 배송지는 기본 배송지로 설정되지 않는다") + void doesNotSetAsDefault_whenNotFirstAddress() { + // arrange + registerAddress(member.getLoginId(), "Password123!", "홍길동", "010-1234-5678", null, "서울시", null); + + // act + ResponseEntity> response = registerAddressWithResponse( + member.getLoginId(), "Password123!", + "김철수", "010-9999-8888", null, "부산시", null + ); + + // assert + assertThat(response.getBody().data().isDefault()).isFalse(); + } + + @Test + @DisplayName("배송지가 5개를 초과하면 400 Bad Request를 반환한다") + void returnsBadRequest_whenExceedsMaxAddresses() { + // arrange + for (int i = 1; i <= 5; i++) { + registerAddress(member.getLoginId(), "Password123!", "홍길동" + i, "010-1234-567" + i, null, "서울시", null); + } + + // act + ResponseEntity> response = registerAddressWithError( + member.getLoginId(), "Password123!", + "김철수", "010-9999-8888", null, "부산시", null + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("인증 실패 시 401 Unauthorized를 반환한다") + void returnsUnauthorized_whenAuthenticationFails() { + // act + ResponseEntity> response = registerAddressWithError( + member.getLoginId(), "WrongPassword!", + "홍길동", "010-1234-5678", null, "서울시", null + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + private ResponseEntity> registerAddressWithResponse( + String loginId, String password, String recipientName, String phone, String zipCode, String address, String addressDetail + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.RegisterRequest request = new AddressV1Dto.RegisterRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + return testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> registerAddressWithError( + String loginId, String password, String recipientName, String phone, String zipCode, String address, String addressDetail + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.RegisterRequest request = new AddressV1Dto.RegisterRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + return testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("PUT /api/v1/addresses/{addressId} - 배송지 수정") + @Nested + class UpdateAddress { + + private Member member; + private Long addressId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + addressId = registerAddressAndGetId(member.getLoginId(), "Password123!", "홍길동", "010-1234-5678", "06234", "서울시", "101호"); + } + + @Test + @DisplayName("배송지를 수정하면 200 OK를 반환한다") + void returnsOk_whenUpdateAddress() { + // act + ResponseEntity> response = updateAddress( + addressId, member.getLoginId(), "Password123!", + "김철수", "010-9999-8888", "12345", "부산시", "201호" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().recipientName()).isEqualTo("김철수"), + () -> assertThat(response.getBody().data().phone()).isEqualTo("010-9999-8888") + ); + } + + @Test + @DisplayName("존재하지 않는 배송지를 수정하면 404 Not Found를 반환한다") + void returnsNotFound_whenAddressNotExists() { + // act + ResponseEntity> response = updateAddressWithError( + 999L, member.getLoginId(), "Password123!", + "김철수", "010-9999-8888", null, "부산시", null + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 수정하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNotOwner() { + // arrange + Member otherMember = saveMember("user2", "Password123!"); + + // act + ResponseEntity> response = updateAddressWithError( + addressId, otherMember.getLoginId(), "Password123!", + "김철수", "010-9999-8888", null, "부산시", null + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private ResponseEntity> updateAddress( + Long addressId, String loginId, String password, + String recipientName, String phone, String zipCode, String address, String addressDetail + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.UpdateRequest request = new AddressV1Dto.UpdateRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> updateAddressWithError( + Long addressId, String loginId, String password, + String recipientName, String phone, String zipCode, String address, String addressDetail + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.UpdateRequest request = new AddressV1Dto.UpdateRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("DELETE /api/v1/addresses/{addressId} - 배송지 삭제") + @Nested + class DeleteAddress { + + private Member member; + private Long addressId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + addressId = registerAddressAndGetId(member.getLoginId(), "Password123!", "홍길동", "010-1234-5678", "06234", "서울시", "101호"); + } + + @Test + @DisplayName("배송지를 삭제하면 200 OK를 반환한다") + void returnsOk_whenDeleteAddress() { + // act + ResponseEntity> response = deleteAddress(addressId, member.getLoginId(), "Password123!"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("삭제된 배송지는 목록에서 조회되지 않는다") + void deletedAddressNotShownInList() { + // arrange + deleteAddress(addressId, member.getLoginId(), "Password123!"); + + // act + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getBody().data()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 배송지를 삭제하면 404 Not Found를 반환한다") + void returnsNotFound_whenAddressNotExists() { + // act + ResponseEntity> response = deleteAddressWithError(999L, member.getLoginId(), "Password123!"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 삭제하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNotOwner() { + // arrange + Member otherMember = saveMember("user2", "Password123!"); + + // act + ResponseEntity> response = deleteAddressWithError(addressId, otherMember.getLoginId(), "Password123!"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("삭제 후 남은 배송지가 1개이면 자동으로 기본 배송지로 설정된다") + void setsAsDefault_whenOneAddressRemains() { + // arrange - 두 번째 배송지 등록 (첫 번째는 setUp에서 등록됨, 기본 배송지) + Long secondAddressId = registerAddressAndGetId(member.getLoginId(), "Password123!", "김철수", "010-9999-8888", null, "부산시", null); + + // 첫 번째 배송지(기본 배송지) 삭제 + deleteAddress(addressId, member.getLoginId(), "Password123!"); + + // act - 남은 배송지 목록 조회 + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + List addresses = response.getBody().data(); + assertAll( + () -> assertThat(addresses).hasSize(1), + () -> assertThat(addresses.get(0).id()).isEqualTo(secondAddressId), + () -> assertThat(addresses.get(0).isDefault()).isTrue() + ); + } + + private ResponseEntity> deleteAddress(Long addressId, String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> deleteAddressWithError(Long addressId, String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("PATCH /api/v1/addresses/{addressId}/default - 기본 배송지 설정") + @Nested + class SetDefaultAddress { + + private Member member; + private Long addressId1; + private Long addressId2; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + addressId1 = registerAddressAndGetId(member.getLoginId(), "Password123!", "홍길동", "010-1234-5678", null, "서울시", null); + addressId2 = registerAddressAndGetId(member.getLoginId(), "Password123!", "김철수", "010-9999-8888", null, "부산시", null); + } + + @Test + @DisplayName("기본 배송지를 설정하면 200 OK를 반환한다") + void returnsOk_whenSetDefault() { + // act + ResponseEntity> response = setDefaultAddress( + addressId2, member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().isDefault()).isTrue() + ); + } + + @Test + @DisplayName("기본 배송지 설정 시 기존 기본 배송지가 해제된다") + void unsetsExistingDefault_whenSettingNewDefault() { + // arrange - addressId1이 첫 번째로 등록되어 기본 배송지임 + setDefaultAddress(addressId2, member.getLoginId(), "Password123!"); + + // act + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + List addresses = response.getBody().data(); + AddressV1Dto.AddressResponse address1 = addresses.stream() + .filter(a -> a.id().equals(addressId1)).findFirst().orElseThrow(); + AddressV1Dto.AddressResponse address2 = addresses.stream() + .filter(a -> a.id().equals(addressId2)).findFirst().orElseThrow(); + + assertAll( + () -> assertThat(address1.isDefault()).isFalse(), + () -> assertThat(address2.isDefault()).isTrue() + ); + } + + @Test + @DisplayName("존재하지 않는 배송지를 기본으로 설정하면 404 Not Found를 반환한다") + void returnsNotFound_whenAddressNotExists() { + // act + ResponseEntity> response = setDefaultAddressWithError(999L, member.getLoginId(), "Password123!"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("다른 회원의 배송지를 기본으로 설정하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNotOwner() { + // arrange + Member otherMember = saveMember("user2", "Password123!"); + + // act + ResponseEntity> response = setDefaultAddressWithError( + addressId1, otherMember.getLoginId(), "Password123!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private ResponseEntity> setDefaultAddress(Long addressId, String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId + "/default", + HttpMethod.PATCH, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> setDefaultAddressWithError(Long addressId, String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/addresses/" + addressId + "/default", + HttpMethod.PATCH, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "Test User", + LocalDate.of(1990, 1, 1), loginId + "@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private void registerAddress(String loginId, String password, String recipientName, String phone, String zipCode, String address, String addressDetail) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.RegisterRequest request = new AddressV1Dto.RegisterRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerAddressAndGetId(String loginId, String password, String recipientName, String phone, String zipCode, String address, String addressDetail) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + + AddressV1Dto.RegisterRequest request = new AddressV1Dto.RegisterRequest( + recipientName, phone, zipCode, address, addressDetail + ); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/addresses", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + return response.getBody().data().id(); + } +} diff --git a/http/address-v1.http b/http/address-v1.http new file mode 100644 index 000000000..e8c0750dd --- /dev/null +++ b/http/address-v1.http @@ -0,0 +1,56 @@ +### 배송지 목록 조회 +GET {{host}}/api/v1/addresses +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +### 배송지 등록 +POST {{host}}/api/v1/addresses +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +{ + "recipientName": "홍길동", + "phone": "010-1234-5678", + "zipCode": "06234", + "address": "서울시 강남구 테헤란로 123", + "addressDetail": "5층 501호" +} + +### 배송지 등록 (두 번째) +POST {{host}}/api/v1/addresses +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +{ + "recipientName": "김철수", + "phone": "010-9999-8888", + "zipCode": "12345", + "address": "부산시 해운대구 해운대로 456", + "addressDetail": "10층 1001호" +} + +### 배송지 수정 +PUT {{host}}/api/v1/addresses/1 +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +{ + "recipientName": "홍길동 (수정)", + "phone": "010-1111-2222", + "zipCode": "06234", + "address": "서울시 강남구 테헤란로 123 (수정)", + "addressDetail": "6층 601호" +} + +### 기본 배송지 설정 +PATCH {{host}}/api/v1/addresses/2/default +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +### 배송지 삭제 +DELETE {{host}}/api/v1/addresses/1 +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 From 8ba07a1267c1a6a4e111d9d6306b5a6270a82e8a Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:09:38 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brand Entity, Repository, Service 계층 구현 - BrandFacade 유즈케이스 오케스트레이션 - Brand Admin API (CRUD) 구현 - Brand 사용자 API (목록/상세 조회) 구현 - N+1 쿼리 최적화를 위한 배치 조회 메서드 추가 - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../application/brand/BrandCommand.java | 16 + .../application/brand/BrandDetailInfo.java | 29 ++ .../application/brand/BrandFacade.java | 64 +++ .../loopers/application/brand/BrandInfo.java | 21 + .../java/com/loopers/domain/brand/Brand.java | 73 +++ .../loopers/domain/brand/BrandRepository.java | 26 ++ .../loopers/domain/brand/BrandService.java | 91 ++++ .../infrastructure/brand/BrandEntity.java | 73 +++ .../brand/BrandJpaRepository.java | 19 + .../brand/BrandRepositoryImpl.java | 83 ++++ .../api/brand/BrandAdminV1ApiSpec.java | 41 ++ .../api/brand/BrandAdminV1Controller.java | 87 ++++ .../interfaces/api/brand/BrandAdminV1Dto.java | 54 +++ .../interfaces/api/brand/BrandV1ApiSpec.java | 23 + .../api/brand/BrandV1Controller.java | 36 ++ .../interfaces/api/brand/BrandV1Dto.java | 22 + .../application/brand/BrandFacadeTest.java | 269 +++++++++++ .../domain/brand/BrandServiceTest.java | 426 ++++++++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 250 ++++++++++ .../api/brand/BrandAdminV1ApiE2ETest.java | 376 ++++++++++++++++ .../api/brand/BrandV1ApiE2ETest.java | 165 +++++++ http/brand-admin-v1.http | 35 ++ http/brand-v1.http | 7 + 23 files changed, 2286 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDetailInfo.java 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/BrandEntity.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/BrandAdminV1ApiSpec.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/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.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/application/brand/BrandFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.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/BrandAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java create mode 100644 http/brand-admin-v1.http create mode 100644 http/brand-v1.http 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..6357df3d5 --- /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 class BrandCommand { + + public record Create( + String name, + String description, + String logoImageUrl + ) {} + + public record Update( + String name, + String description, + String logoImageUrl + ) {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDetailInfo.java new file mode 100644 index 000000000..9586481cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDetailInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.LocalDateTime; + +public record BrandDetailInfo( + Long id, + String name, + String description, + String logoImageUrl, + Long likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { + public static BrandDetailInfo from(Brand brand) { + return new BrandDetailInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoImageUrl(), + brand.getLikeCount(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } +} \ No newline at end of file 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..2f4526479 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,64 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.support.auth.AdminValidator; +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 +public class BrandFacade { + + private final BrandService brandService; + private final AdminValidator adminValidator; + + @Transactional(readOnly = true) + public BrandInfo getBrandInfo(Long brandId) { + Brand brand = brandService.getActiveBrand(brandId); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public Page getBrandInfos(Pageable pageable) { + return brandService.getBrands(pageable) + .map(BrandInfo::from); + } + + @Transactional(readOnly = true) + public BrandDetailInfo getBrandDetail(String ldap, Long brandId) { + adminValidator.validate(ldap); + Brand brand = brandService.getBrand(brandId); + return BrandDetailInfo.from(brand); + } + + @Transactional(readOnly = true) + public Page getBrandDetails(String ldap, Pageable pageable) { + adminValidator.validate(ldap); + return brandService.getBrands(pageable) + .map(BrandDetailInfo::from); + } + + @Transactional + public BrandDetailInfo createBrand(String ldap, BrandCommand.Create command) { + adminValidator.validate(ldap); + Brand brand = brandService.createBrand(command.name(), command.description(), command.logoImageUrl()); + return BrandDetailInfo.from(brand); + } + + @Transactional + public BrandDetailInfo updateBrand(String ldap, Long brandId, BrandCommand.Update command) { + adminValidator.validate(ldap); + Brand brand = brandService.updateBrand(brandId, command.name(), command.description(), command.logoImageUrl()); + return BrandDetailInfo.from(brand); + } + + @Transactional + public void deleteBrand(String ldap, Long brandId) { + adminValidator.validate(ldap); + brandService.deleteBrand(brandId); + } +} \ No newline at end of file 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..138a4ba8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +public record BrandInfo( + Long id, + String name, + String description, + String logoImageUrl, + Long likeCount +) { + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoImageUrl(), + brand.getLikeCount() + ); + } +} \ No newline at end of file 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..aa0f1a05a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,73 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class Brand { + + private Long id; + private String name; + private String description; + private String logoImageUrl; + private Long likeCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + + public Brand(String name, String description, String logoImageUrl) { + validateName(name); + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + this.likeCount = 0L; + } + + public Brand(Long id, String name, String description, String logoImageUrl, Long likeCount, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + this.likeCount = likeCount != null ? likeCount : 0L; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public void update(String name, String description, String logoImageUrl) { + validateName(name); + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } + + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = LocalDateTime.now(); + } + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } +} \ No newline at end of file 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..7549e9b43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,26 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + + Optional findById(Long id); + + List findAllActive(); + + Page findAllActive(Pageable pageable); + + List findAllActiveByIds(List ids); + + Brand save(Brand brand); + + Brand update(Long id, Brand brand); + + void delete(Long id); + + boolean existsById(Long id); +} \ No newline at end of file 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..a2eb520c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,91 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.product.ProductService; +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.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + private final ProductService productService; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Brand getActiveBrand(Long brandId) { + Brand brand = getBrand(brandId); + if (brand.isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 브랜드입니다."); + } + return brand; + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Page getBrands(Pageable pageable) { + return brandRepository.findAllActive(pageable); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Brand createBrand(String name, String description, String logoImageUrl) { + Brand brand = new Brand(name, description, logoImageUrl); + return brandRepository.save(brand); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Brand updateBrand(Long brandId, String name, String description, String logoImageUrl) { + Brand brand = new Brand(name, description, logoImageUrl); + return brandRepository.update(brandId, brand); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void deleteBrand(Long brandId) { + getBrand(brandId); // 존재 확인 + productService.deleteProductsByBrandId(brandId); + brandRepository.delete(brandId); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Brand validateBrand(Long brandId) { + return getActiveBrand(brandId); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Map getActiveBrandsByIds(List brandIds) { + if (brandIds == null || brandIds.isEmpty()) { + return Map.of(); + } + List brands = brandRepository.findAllActiveByIds(brandIds); + return brands.stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Long increaseLikeCount(Long brandId) { + Brand brand = getBrand(brandId); + brand.increaseLikeCount(); + return brandRepository.save(brand).getLikeCount(); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Long decreaseLikeCount(Long brandId) { + Brand brand = getBrand(brandId); + brand.decreaseLikeCount(); + return brandRepository.save(brand).getLikeCount(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..8478590a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,73 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +@Entity +@Table(name = "brands") +@SQLDelete(sql = "UPDATE brands SET deleted_at = NOW() WHERE id = ?") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandEntity extends BaseEntity { + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "logo_image_url", length = 512) + private String logoImageUrl; + + @Column(name = "like_count", nullable = false) + private Long likeCount = 0L; + + public static BrandEntity from(Brand brand) { + BrandEntity entity = new BrandEntity(); + entity.name = brand.getName(); + entity.description = brand.getDescription(); + entity.logoImageUrl = brand.getLogoImageUrl(); + entity.likeCount = brand.getLikeCount() != null ? brand.getLikeCount() : 0L; + return entity; + } + + public Brand toDomain() { + return new Brand( + getId(), + name, + description, + logoImageUrl, + likeCount, + getCreatedAt() != null ? getCreatedAt().toLocalDateTime() : null, + getUpdatedAt() != null ? getUpdatedAt().toLocalDateTime() : null, + getDeletedAt() != null ? getDeletedAt().toLocalDateTime() : null + ); + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void updateLikeCount(Long likeCount) { + this.likeCount = likeCount != null ? likeCount : 0L; + } + + public void update(String name, String description, String logoImageUrl) { + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } +} \ No newline at end of file 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..6fce4a8fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.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; + +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + + List findByDeletedAtIsNull(); + + Page findByDeletedAtIsNull(Pageable pageable); + + @Query("SELECT b FROM BrandEntity b WHERE b.id IN :ids AND b.deletedAt IS NULL") + List findAllActiveByIdIn(@Param("ids") List ids); +} \ No newline at end of file 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..e4d380f2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,83 @@ +package com.loopers.infrastructure.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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .map(BrandEntity::toDomain); + } + + @Override + public List findAllActive() { + return brandJpaRepository.findByDeletedAtIsNull() + .stream() + .map(BrandEntity::toDomain) + .toList(); + } + + @Override + public Page findAllActive(Pageable pageable) { + return brandJpaRepository.findByDeletedAtIsNull(pageable) + .map(BrandEntity::toDomain); + } + + @Override + public List findAllActiveByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return brandJpaRepository.findAllActiveByIdIn(ids).stream() + .map(BrandEntity::toDomain) + .toList(); + } + + @Override + public Brand save(Brand brand) { + BrandEntity entity; + if (brand.getId() != null) { + entity = brandJpaRepository.findById(brand.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + entity.update(brand.getName(), brand.getDescription(), brand.getLogoImageUrl()); + entity.updateLikeCount(brand.getLikeCount()); + } else { + entity = BrandEntity.from(brand); + } + BrandEntity saved = brandJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public void delete(Long id) { + brandJpaRepository.deleteById(id); + } + + @Override + public Brand update(Long id, Brand brand) { + BrandEntity entity = brandJpaRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + entity.update(brand.getName(), brand.getDescription(), brand.getLogoImageUrl()); + return entity.toDomain(); + } + + @Override + public boolean existsById(Long id) { + return brandJpaRepository.existsById(id); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java new file mode 100644 index 000000000..df6e72e68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Brand Admin V1 API", description = "브랜드 관리자 API 입니다.") +public interface BrandAdminV1ApiSpec { + + @Operation( + summary = "브랜드 목록 조회 (Admin)", + description = "관리자용 브랜드 목록을 페이징하여 조회합니다." + ) + ApiResponse> getBrands(String ldap, Pageable pageable); + + @Operation( + summary = "브랜드 상세 조회 (Admin)", + description = "관리자용 브랜드 상세 정보를 조회합니다." + ) + ApiResponse getBrand(String ldap, Long brandId); + + @Operation( + summary = "브랜드 등록 (Admin)", + description = "새로운 브랜드를 등록합니다." + ) + ApiResponse createBrand(String ldap, BrandAdminV1Dto.CreateBrandRequest request); + + @Operation( + summary = "브랜드 수정 (Admin)", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse updateBrand(String ldap, Long brandId, BrandAdminV1Dto.UpdateBrandRequest request); + + @Operation( + summary = "브랜드 삭제 (Admin)", + description = "브랜드를 삭제합니다. (Soft Delete)" + ) + ApiResponse deleteBrand(String ldap, Long brandId); +} \ No newline at end of file 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..87580733e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,87 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandCommand; +import com.loopers.application.brand.BrandDetailInfo; +import com.loopers.application.brand.BrandFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping + @Override + public ApiResponse> getBrands( + @RequestHeader("X-Loopers-Ldap") String ldap, + Pageable pageable + ) { + Page infos = brandFacade.getBrandDetails(ldap, pageable); + Page response = infos.map(BrandAdminV1Dto.BrandDetailResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId + ) { + BrandDetailInfo info = brandFacade.getBrandDetail(ldap, brandId); + BrandAdminV1Dto.BrandDetailResponse response = BrandAdminV1Dto.BrandDetailResponse.from(info); + return ApiResponse.success(response); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @Valid @RequestBody BrandAdminV1Dto.CreateBrandRequest request + ) { + BrandCommand.Create command = new BrandCommand.Create(request.name(), request.description(), request.logoImageUrl()); + BrandDetailInfo info = brandFacade.createBrand(ldap, command); + BrandAdminV1Dto.BrandDetailResponse response = BrandAdminV1Dto.BrandDetailResponse.from(info); + return ApiResponse.success(response); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse updateBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId, + @Valid @RequestBody BrandAdminV1Dto.UpdateBrandRequest request + ) { + BrandCommand.Update command = new BrandCommand.Update(request.name(), request.description(), request.logoImageUrl()); + BrandDetailInfo info = brandFacade.updateBrand(ldap, brandId, command); + BrandAdminV1Dto.BrandDetailResponse response = BrandAdminV1Dto.BrandDetailResponse.from(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse deleteBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId + ) { + brandFacade.deleteBrand(ldap, brandId); + return ApiResponse.success(); + } +} \ No newline at end of file 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..d39ee6b32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandDetailInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public class BrandAdminV1Dto { + + public record CreateBrandRequest( + @NotBlank(message = "브랜드명은 비어있을 수 없습니다.") + @Size(max = 50, message = "브랜드명은 50자를 초과할 수 없습니다.") + String name, + + String description, + + @Size(max = 512, message = "로고 이미지 URL은 512자를 초과할 수 없습니다.") + String logoImageUrl + ) {} + + public record UpdateBrandRequest( + @NotBlank(message = "브랜드명은 비어있을 수 없습니다.") + @Size(max = 50, message = "브랜드명은 50자를 초과할 수 없습니다.") + String name, + + String description, + + @Size(max = 512, message = "로고 이미지 URL은 512자를 초과할 수 없습니다.") + String logoImageUrl + ) {} + + public record BrandDetailResponse( + Long id, + String name, + String description, + String logoImageUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + public static BrandDetailResponse from(BrandDetailInfo info) { + return new BrandDetailResponse( + info.id(), + info.name(), + info.description(), + info.logoImageUrl(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..76641917a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,23 @@ +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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 목록 조회", + description = "활성 브랜드 목록을 페이징하여 조회합니다." + ) + ApiResponse> getBrands(Pageable pageable); + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드 상세 정보를 조회합니다." + ) + ApiResponse getBrand(Long brandId); +} \ No newline at end of file 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..8349dedfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,36 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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 +@RequiredArgsConstructor +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping + @Override + public ApiResponse> getBrands(Pageable pageable) { + Page infos = brandFacade.getBrandInfos(pageable); + Page response = infos.map(BrandV1Dto.BrandResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrandInfo(brandId); + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); + return ApiResponse.success(response); + } +} \ No newline at end of file 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..4ba3bd999 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description, + String logoImageUrl + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), + info.name(), + info.description(), + info.logoImageUrl() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..bdb4d4a4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,269 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("BrandFacade 통합 테스트") +class BrandFacadeTest { + + @Autowired + private BrandFacade brandFacade; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String VALID_ADMIN_LDAP = "loopers.admin"; + private static final String INVALID_ADMIN_LDAP = "invalid.ldap"; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getBrandInfo (사용자용)") + class GetBrandInfo { + + @Test + @DisplayName("활성 브랜드 정보를 조회하면 BrandInfo를 반환한다") + void returnsBrandInfo_whenBrandIsActive() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act + BrandInfo result = brandFacade.getBrandInfo(saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(saved.getId()), + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(result.logoImageUrl()).isEqualTo("https://logo.png") + ); + } + + @Test + @DisplayName("삭제된 브랜드를 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandIsDeleted() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + brandService.deleteBrand(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> brandFacade.getBrandInfo(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getBrandInfos (사용자용 목록)") + class GetBrandInfos { + + @Test + @DisplayName("활성 브랜드 목록을 조회하면 BrandInfo 페이지를 반환한다") + void returnsBrandInfoPage_whenBrandsExist() { + // Arrange + brandService.createBrand("Nike", "스포츠", "https://nike.png"); + brandService.createBrand("Adidas", "독일", "https://adidas.png"); + + // Act + Page result = brandFacade.getBrandInfos(PageRequest.of(0, 10)); + + // Assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).extracting(BrandInfo::name) + .containsExactlyInAnyOrder("Nike", "Adidas") + ); + } + } + + @Nested + @DisplayName("getBrandDetail (Admin용)") + class GetBrandDetail { + + @Test + @DisplayName("Admin이 브랜드 상세를 조회하면 BrandDetailInfo를 반환한다") + void returnsBrandDetailInfo_whenAdminRequests() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act + BrandDetailInfo result = brandFacade.getBrandDetail(VALID_ADMIN_LDAP, saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(saved.getId()), + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.createdAt()).isNotNull(), + () -> assertThat(result.deletedAt()).isNull() + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 브랜드 상세를 조회하면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNonAdminRequests() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brandFacade.getBrandDetail(INVALID_ADMIN_LDAP, saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("getBrandDetails (Admin용 목록)") + class GetBrandDetails { + + @Test + @DisplayName("Admin이 브랜드 목록을 조회하면 BrandDetailInfo 페이지를 반환한다") + void returnsBrandDetailInfoPage_whenAdminRequests() { + // Arrange + brandService.createBrand("Nike", "스포츠", "https://nike.png"); + brandService.createBrand("Adidas", "독일", "https://adidas.png"); + + // Act + Page result = brandFacade.getBrandDetails(VALID_ADMIN_LDAP, PageRequest.of(0, 10)); + + // Assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).extracting(BrandDetailInfo::name) + .containsExactlyInAnyOrder("Nike", "Adidas") + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 브랜드 목록을 조회하면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNonAdminRequests() { + // Act & Assert + assertThatThrownBy(() -> brandFacade.getBrandDetails(INVALID_ADMIN_LDAP, PageRequest.of(0, 10))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("createBrand (Admin용)") + class CreateBrand { + + @Test + @DisplayName("Admin이 브랜드를 등록하면 BrandDetailInfo를 반환한다") + void returnsBrandDetailInfo_whenAdminCreates() { + // Arrange + BrandCommand.Create command = new BrandCommand.Create("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act + BrandDetailInfo result = brandFacade.createBrand(VALID_ADMIN_LDAP, command); + + // Assert + assertAll( + () -> assertThat(result.id()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("Nike"), + () -> assertThat(result.createdAt()).isNotNull() + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 브랜드를 등록하면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNonAdminCreates() { + // Arrange + BrandCommand.Create command = new BrandCommand.Create("Nike", "설명", "https://logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brandFacade.createBrand(INVALID_ADMIN_LDAP, command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("updateBrand (Admin용)") + class UpdateBrand { + + @Test + @DisplayName("Admin이 브랜드를 수정하면 BrandDetailInfo를 반환한다") + void returnsBrandDetailInfo_whenAdminUpdates() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + BrandCommand.Update command = new BrandCommand.Update("Adidas", "독일 브랜드", "https://adidas.png"); + + // Act + BrandDetailInfo result = brandFacade.updateBrand(VALID_ADMIN_LDAP, saved.getId(), command); + + // Assert + assertAll( + () -> assertThat(result.name()).isEqualTo("Adidas"), + () -> assertThat(result.description()).isEqualTo("독일 브랜드"), + () -> assertThat(result.logoImageUrl()).isEqualTo("https://adidas.png") + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 브랜드를 수정하면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNonAdminUpdates() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + BrandCommand.Update command = new BrandCommand.Update("Adidas", "설명", "https://logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brandFacade.updateBrand(INVALID_ADMIN_LDAP, saved.getId(), command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("deleteBrand (Admin용)") + class DeleteBrand { + + @Test + @DisplayName("Admin이 브랜드를 삭제하면 정상 처리된다") + void deletesSuccessfully_whenAdminDeletes() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act + brandFacade.deleteBrand(VALID_ADMIN_LDAP, saved.getId()); + + // Assert + assertThatThrownBy(() -> brandFacade.getBrandInfo(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 브랜드를 삭제하면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNonAdminDeletes() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brandFacade.deleteBrand(INVALID_ADMIN_LDAP, saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..8c30770e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,426 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("BrandService 통합 테스트") +class BrandServiceTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getBrand") + class GetBrand { + + @Test + @DisplayName("존재하는 브랜드를 조회하면 Brand를 반환한다") + void returnsBrand_whenBrandExists() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + Brand result = brandService.getBrand(saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getName()).isEqualTo("Nike") + ); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandNotExists() { + // Arrange + Long nonExistentId = 999L; + + // Act & Assert + assertThatThrownBy(() -> brandService.getBrand(nonExistentId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getActiveBrand") + class GetActiveBrand { + + @Test + @DisplayName("활성 브랜드를 조회하면 Brand를 반환한다") + void returnsBrand_whenBrandIsActive() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + Brand result = brandService.getActiveBrand(saved.getId()); + + // Assert + assertThat(result.getName()).isEqualTo("Nike"); + } + + @Test + @DisplayName("삭제된 브랜드를 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandIsDeleted() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://logo.png"); + Brand saved = brandRepository.save(brand); + brandService.deleteBrand(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> brandService.getActiveBrand(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getBrands") + class GetBrands { + + @Test + @DisplayName("삭제되지 않은 브랜드만 조회한다") + void returnsOnlyActiveBrands() { + // Arrange + brandRepository.save(new Brand("Nike", "설명1", "https://logo1.png")); + brandRepository.save(new Brand("Adidas", "설명2", "https://logo2.png")); + Brand toDelete = brandRepository.save(new Brand("Puma", "설명3", "https://logo3.png")); + brandService.deleteBrand(toDelete.getId()); + + // Act + Page result = brandService.getBrands(PageRequest.of(0, 10)); + + // Assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).extracting(Brand::getName) + .containsExactlyInAnyOrder("Nike", "Adidas") + ); + } + + @Test + @DisplayName("페이징이 정상 동작한다") + void returnsPaginatedResults() { + // Arrange + for (int i = 1; i <= 25; i++) { + brandRepository.save(new Brand("Brand" + i, "설명" + i, "https://logo" + i + ".png")); + } + + // Act + Page page1 = brandService.getBrands(PageRequest.of(0, 10)); + Page page2 = brandService.getBrands(PageRequest.of(1, 10)); + Page page3 = brandService.getBrands(PageRequest.of(2, 10)); + + // Assert + assertAll( + () -> assertThat(page1.getContent()).hasSize(10), + () -> assertThat(page2.getContent()).hasSize(10), + () -> assertThat(page3.getContent()).hasSize(5), + () -> assertThat(page1.getTotalElements()).isEqualTo(25), + () -> assertThat(page1.getTotalPages()).isEqualTo(3) + ); + } + } + + @Nested + @DisplayName("createBrand") + class CreateBrand { + + @Test + @DisplayName("브랜드를 정상적으로 생성한다") + void createsBrand() { + // Arrange & Act + Brand result = brandService.createBrand("Nike", "스포츠 브랜드", "https://logo.png"); + + // Assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("Nike"), + () -> assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"), + () -> assertThat(result.getLogoImageUrl()).isEqualTo("https://logo.png") + ); + } + } + + @Nested + @DisplayName("updateBrand") + class UpdateBrand { + + @Test + @DisplayName("브랜드 정보를 정상적으로 수정한다") + void updatesBrand() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + Brand result = brandService.updateBrand(saved.getId(), "Adidas", "독일 브랜드", "https://adidas.png"); + + // Assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("Adidas"), + () -> assertThat(result.getDescription()).isEqualTo("독일 브랜드"), + () -> assertThat(result.getLogoImageUrl()).isEqualTo("https://adidas.png") + ); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 수정하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandNotExists() { + // Arrange + Long nonExistentId = 999L; + + // Act & Assert + assertThatThrownBy(() -> brandService.updateBrand(nonExistentId, "Adidas", "설명", "https://logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("deleteBrand") + class DeleteBrand { + + @Test + @DisplayName("브랜드를 삭제하면 Soft Delete 된다") + void deletesBrand() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + brandService.deleteBrand(saved.getId()); + + // Assert + Brand deleted = brandService.getBrand(saved.getId()); + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 삭제하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandNotExists() { + // Arrange + Long nonExistentId = 999L; + + // Act & Assert + assertThatThrownBy(() -> brandService.deleteBrand(nonExistentId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("브랜드 삭제 시 연관된 상품도 함께 Soft Delete 된다") + void deletesRelatedProducts_whenBrandDeleted() { + // Arrange + Brand brand = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + Category category = categoryRepository.save(new Category("스포츠")); + Product product1 = productService.createProduct("나이키 신발", brand.getId(), category.getId(), 100000L); + Product product2 = productService.createProduct("나이키 가방", brand.getId(), category.getId(), 50000L); + + // Act + brandService.deleteBrand(brand.getId()); + + // Assert + Product deletedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); + Product deletedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); + assertAll( + () -> assertThat(deletedProduct1.isDeleted()).isTrue(), + () -> assertThat(deletedProduct2.isDeleted()).isTrue() + ); + } + + @Test + @DisplayName("연관 상품이 없는 브랜드 삭제 시 정상 동작한다") + void deletesSuccessfully_whenNoRelatedProducts() { + // Arrange + Brand brand = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + brandService.deleteBrand(brand.getId()); + + // Assert + Brand deleted = brandService.getBrand(brand.getId()); + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("다른 브랜드의 상품은 영향받지 않는다") + void doesNotAffectOtherBrandProducts_whenBrandDeleted() { + // Arrange + Brand nike = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://nike.png")); + Brand adidas = brandRepository.save(new Brand("Adidas", "독일 브랜드", "https://adidas.png")); + Category category = categoryRepository.save(new Category("스포츠")); + Product nikeProduct = productService.createProduct("나이키 신발", nike.getId(), category.getId(), 100000L); + Product adidasProduct = productService.createProduct("아디다스 신발", adidas.getId(), category.getId(), 120000L); + + // Act + brandService.deleteBrand(nike.getId()); + + // Assert + Product deletedNikeProduct = productRepository.findById(nikeProduct.getId()).orElseThrow(); + Product activeAdidasProduct = productRepository.findById(adidasProduct.getId()).orElseThrow(); + assertAll( + () -> assertThat(deletedNikeProduct.isDeleted()).isTrue(), + () -> assertThat(activeAdidasProduct.isDeleted()).isFalse() + ); + } + } + + @Nested + @DisplayName("validateBrand") + class ValidateBrand { + + @Test + @DisplayName("존재하고 활성인 브랜드를 검증하면 Brand를 반환한다") + void returnsBrand_whenBrandIsValid() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + + // Act + Brand result = brandService.validateBrand(saved.getId()); + + // Assert + assertThat(result.getName()).isEqualTo("Nike"); + } + + @Test + @DisplayName("삭제된 브랜드를 검증하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandIsDeleted() { + // Arrange + Brand saved = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + brandService.deleteBrand(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> brandService.validateBrand(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getActiveBrandsByIds") + class GetActiveBrandsByIds { + + @Test + @DisplayName("여러 브랜드 ID로 한 번에 조회한다") + void returnsBrands_whenIdsProvided() { + // Arrange + Brand nike = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://nike.png")); + Brand adidas = brandRepository.save(new Brand("Adidas", "독일 브랜드", "https://adidas.png")); + Brand puma = brandRepository.save(new Brand("Puma", "유럽 브랜드", "https://puma.png")); + List brandIds = List.of(nike.getId(), adidas.getId(), puma.getId()); + + // Act + Map result = brandService.getActiveBrandsByIds(brandIds); + + // Assert + assertAll( + () -> assertThat(result).hasSize(3), + () -> assertThat(result.get(nike.getId()).getName()).isEqualTo("Nike"), + () -> assertThat(result.get(adidas.getId()).getName()).isEqualTo("Adidas"), + () -> assertThat(result.get(puma.getId()).getName()).isEqualTo("Puma") + ); + } + + @Test + @DisplayName("삭제된 브랜드는 결과에 포함되지 않는다") + void excludesDeletedBrands() { + // Arrange + Brand nike = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://nike.png")); + Brand adidas = brandRepository.save(new Brand("Adidas", "독일 브랜드", "https://adidas.png")); + brandService.deleteBrand(adidas.getId()); + List brandIds = List.of(nike.getId(), adidas.getId()); + + // Act + Map result = brandService.getActiveBrandsByIds(brandIds); + + // Assert + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.containsKey(nike.getId())).isTrue(), + () -> assertThat(result.containsKey(adidas.getId())).isFalse() + ); + } + + @Test + @DisplayName("빈 ID 목록이면 빈 Map을 반환한다") + void returnsEmptyMap_whenIdsIsEmpty() { + // Arrange + List emptyIds = List.of(); + + // Act + Map result = brandService.getActiveBrandsByIds(emptyIds); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("null ID 목록이면 빈 Map을 반환한다") + void returnsEmptyMap_whenIdsIsNull() { + // Act + Map result = brandService.getActiveBrandsByIds(null); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 ID는 결과에 포함되지 않는다") + void excludesNonExistentIds() { + // Arrange + Brand nike = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://nike.png")); + List brandIds = List.of(nike.getId(), 9999L); + + // Act + Map result = brandService.getActiveBrandsByIds(brandIds); + + // Assert + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.containsKey(nike.getId())).isTrue(), + () -> assertThat(result.containsKey(9999L)).isFalse() + ); + } + } +} \ No newline at end of file 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..517c8db7a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,250 @@ +package com.loopers.domain.brand; + +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 java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Brand 도메인 단위 테스트") +class BrandTest { + + @Nested + @DisplayName("Brand 생성") + class Create { + + @Test + @DisplayName("모든 값이 유효하면 정상적으로 생성된다") + void createsBrand_whenAllFieldsAreValid() { + // Arrange & Act + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Nike"), + () -> assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"), + () -> assertThat(brand.getLogoImageUrl()).isEqualTo("https://example.com/nike-logo.png"), + () -> assertThat(brand.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsNull() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Brand(null, "설명", "https://example.com/logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name이 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsEmpty() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Brand("", "설명", "https://example.com/logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name이 공백 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsBlank() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Brand(" ", "설명", "https://example.com/logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("description과 logoImageUrl이 null이어도 생성된다") + void createsBrand_whenOptionalFieldsAreNull() { + // Arrange & Act + Brand brand = new Brand("Nike", null, null); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Nike"), + () -> assertThat(brand.getDescription()).isNull(), + () -> assertThat(brand.getLogoImageUrl()).isNull() + ); + } + } + + @Nested + @DisplayName("Brand update") + class Update { + + @Test + @DisplayName("모든 필드를 정상적으로 업데이트한다") + void updatesAllFields() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act + brand.update("Adidas", "독일 스포츠 브랜드", "https://example.com/adidas-logo.png"); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Adidas"), + () -> assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드"), + () -> assertThat(brand.getLogoImageUrl()).isEqualTo("https://example.com/adidas-logo.png") + ); + } + + @Test + @DisplayName("name을 null로 업데이트하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUpdateNameToNull() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brand.update(null, "설명", "https://example.com/logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name을 빈 문자열로 업데이트하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUpdateNameToEmpty() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act & Assert + assertThatThrownBy(() -> brand.update("", "설명", "https://example.com/logo.png")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("Brand delete") + class Delete { + + @Test + @DisplayName("delete 호출 시 deletedAt이 설정된다") + void setsDeletedAt_whenDeleteCalled() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act + brand.delete(); + + // Assert + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상태에서 delete 호출해도 예외가 발생하지 않는다 (멱등성)") + void doesNotThrow_whenDeleteCalledTwice() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + brand.delete(); + + // Act & Assert (멱등성 - 예외 없이 정상 실행) + brand.delete(); + assertThat(brand.isDeleted()).isTrue(); + } + } + + @Nested + @DisplayName("likeCount - 좋아요 수 관리") + class LikeCount { + + @Test + @DisplayName("생성 시 likeCount 기본값은 0이다") + void likeCountIsZero_whenCreated() { + // Arrange & Act + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("increaseLikeCount 호출 시 likeCount가 1 증가한다") + void increasesLikeCount() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act + brand.increaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("decreaseLikeCount 호출 시 likeCount가 1 감소한다") + void decreasesLikeCount() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + brand.increaseLikeCount(); + brand.increaseLikeCount(); + + // Act + brand.decreaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("likeCount가 0일 때 decreaseLikeCount 호출해도 0 미만이 되지 않는다") + void doesNotGoBelowZero_whenDecreaseCalledAtZero() { + // Arrange + Brand brand = new Brand("Nike", "스포츠 브랜드", "https://example.com/nike-logo.png"); + + // Act + brand.decreaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("DB 조회 데이터 복원 (toDomain 용)") + class RestoreFromDatabase { + + @Test + @DisplayName("DB에서 조회한 데이터로 Brand 도메인 객체를 복원한다") + void restoresBrandFromDatabaseRecord() { + // Arrange + LocalDateTime createdAt = LocalDateTime.of(2024, 1, 1, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2024, 1, 2, 10, 0); + + // Act + Brand brand = new Brand(1L, "Nike", "스포츠 브랜드", "https://example.com/nike-logo.png", 0L, + createdAt, updatedAt, null); + + // Assert + assertAll( + () -> assertThat(brand.getId()).isEqualTo(1L), + () -> assertThat(brand.getName()).isEqualTo("Nike"), + () -> assertThat(brand.getCreatedAt()).isEqualTo(createdAt), + () -> assertThat(brand.getUpdatedAt()).isEqualTo(updatedAt), + () -> assertThat(brand.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("삭제된 브랜드 데이터를 복원하면 isDeleted가 true를 반환한다") + void returnsTrue_whenRestoredBrandWasDeleted() { + // Arrange + LocalDateTime now = LocalDateTime.now(); + + // Act + Brand brand = new Brand(1L, "Nike", "스포츠 브랜드", "https://example.com/nike-logo.png", 0L, + now, now, now); + + // Assert + assertThat(brand.isDeleted()).isTrue(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java new file mode 100644 index 000000000..35995f59b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java @@ -0,0 +1,376 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Brand Admin V1 API E2E 테스트") +class BrandAdminV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/admin/brands"; + private static final String VALID_ADMIN_LDAP = "loopers.admin"; + private static final String INVALID_ADMIN_LDAP = "invalid.ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private HttpHeaders createInvalidAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", INVALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + @Nested + @DisplayName("GET /api/v1/admin/brands") + class GetBrands { + + @Test + @DisplayName("Admin이 브랜드 목록을 조회하면 200 OK를 반환한다") + void returnsOk_whenAdminRequests() { + // Arrange + brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + brandService.createBrand("Adidas", "독일 브랜드", "https://adidas.png"); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 조회하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminRequests() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @Nested + @DisplayName("GET /api/v1/admin/brands/{brandId}") + class GetBrand { + + @Test + @DisplayName("Admin이 브랜드 상세를 조회하면 200 OK를 반환한다") + void returnsOk_whenAdminRequests() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("Nike"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 조회하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminRequests() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 조회하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("POST /api/v1/admin/brands") + class CreateBrand { + + @Test + @DisplayName("Admin이 브랜드를 등록하면 201 Created를 반환한다") + void returnsCreated_whenAdminCreates() { + // Arrange + BrandAdminV1Dto.CreateBrandRequest request = new BrandAdminV1Dto.CreateBrandRequest( + "Nike", "스포츠 브랜드", "https://nike.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().name()).isEqualTo("Nike") + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 등록하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminCreates() { + // Arrange + BrandAdminV1Dto.CreateBrandRequest request = new BrandAdminV1Dto.CreateBrandRequest( + "Nike", "스포츠 브랜드", "https://nike.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("필수 필드가 누락되면 400 Bad Request를 반환한다") + void returnsBadRequest_whenNameMissing() { + // Arrange + BrandAdminV1Dto.CreateBrandRequest request = new BrandAdminV1Dto.CreateBrandRequest( + null, "스포츠 브랜드", "https://nike.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("PUT /api/v1/admin/brands/{brandId}") + class UpdateBrand { + + @Test + @DisplayName("Admin이 브랜드를 수정하면 200 OK를 반환한다") + void returnsOk_whenAdminUpdates() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + BrandAdminV1Dto.UpdateBrandRequest request = new BrandAdminV1Dto.UpdateBrandRequest( + "Adidas", "독일 브랜드", "https://adidas.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.PUT, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("Adidas"), + () -> assertThat(response.getBody().data().description()).isEqualTo("독일 브랜드") + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 수정하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminUpdates() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + BrandAdminV1Dto.UpdateBrandRequest request = new BrandAdminV1Dto.UpdateBrandRequest( + "Adidas", "독일 브랜드", "https://adidas.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.PUT, + new HttpEntity<>(request, createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 수정하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // Arrange + BrandAdminV1Dto.UpdateBrandRequest request = new BrandAdminV1Dto.UpdateBrandRequest( + "Adidas", "독일 브랜드", "https://adidas.png" + ); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.PUT, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("DELETE /api/v1/admin/brands/{brandId}") + class DeleteBrand { + + @Test + @DisplayName("Admin이 브랜드를 삭제하면 200 OK를 반환한다") + void returnsOk_whenAdminDeletes() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 삭제하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminDeletes() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 삭제하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..8d85d3805 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,165 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.Brand; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Brand V1 API E2E 테스트") +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/brands") + class GetBrands { + + @Test + @DisplayName("브랜드 목록을 조회하면 200 OK와 페이징된 목록을 반환한다") + void returnsOk_whenGetBrands() { + // Arrange + brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + brandService.createBrand("Adidas", "독일 브랜드", "https://adidas.png"); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(2) + ); + } + + @Test + @DisplayName("삭제된 브랜드는 목록에서 제외된다") + void excludesDeletedBrands() { + // Arrange + brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + Brand toDelete = brandService.createBrand("Adidas", "독일 브랜드", "https://adidas.png"); + brandService.deleteBrand(toDelete.getId()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(1) + ); + } + + @Test + @DisplayName("페이징이 정상 동작한다") + void returnsPaginatedResults() { + // Arrange + for (int i = 1; i <= 15; i++) { + brandService.createBrand("Brand" + i, "설명" + i, "https://logo" + i + ".png"); + } + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=1&size=10", HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(15), + () -> assertThat(response.getBody().data().get("totalPages")).isEqualTo(2), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(5) + ); + } + } + + @Nested + @DisplayName("GET /api/v1/brands/{brandId}") + class GetBrand { + + @Test + @DisplayName("존재하는 브랜드를 조회하면 200 OK와 브랜드 정보를 반환한다") + void returnsOk_whenBrandExists() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("Nike"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().logoImageUrl()).isEqualTo("https://nike.png") + ); + } + + @Test + @DisplayName("존재하지 않는 브랜드를 조회하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/999", HttpMethod.GET, null, responseType); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("삭제된 브랜드를 조회하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandIsDeleted() { + // Arrange + Brand saved = brandService.createBrand("Nike", "스포츠 브랜드", "https://nike.png"); + brandService.deleteBrand(saved.getId()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.GET, null, responseType); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/http/brand-admin-v1.http b/http/brand-admin-v1.http new file mode 100644 index 000000000..f48bf6eb1 --- /dev/null +++ b/http/brand-admin-v1.http @@ -0,0 +1,35 @@ +### 브랜드 목록 조회 (Admin) +GET http://localhost:8080/api/v1/admin/brands?page=0&size=10 +Accept: application/json +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 (Admin) +GET http://localhost:8080/api/v1/admin/brands/1 +Accept: application/json +X-Loopers-Ldap: loopers.admin + +### 브랜드 등록 (Admin) +POST http://localhost:8080/api/v1/admin/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Nike", + "description": "스포츠 브랜드", + "logoImageUrl": "https://example.com/nike-logo.png" +} + +### 브랜드 수정 (Admin) +PUT http://localhost:8080/api/v1/admin/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "Adidas", + "description": "독일 스포츠 브랜드", + "logoImageUrl": "https://example.com/adidas-logo.png" +} + +### 브랜드 삭제 (Admin) +DELETE http://localhost:8080/api/v1/admin/brands/1 +X-Loopers-Ldap: loopers.admin \ No newline at end of file diff --git a/http/brand-v1.http b/http/brand-v1.http new file mode 100644 index 000000000..28b20802d --- /dev/null +++ b/http/brand-v1.http @@ -0,0 +1,7 @@ +### 브랜드 목록 조회 +GET http://localhost:8080/api/v1/brands?page=0&size=10 +Accept: application/json + +### 브랜드 상세 조회 +GET http://localhost:8080/api/v1/brands/1 +Accept: application/json \ No newline at end of file From 33c8730a74c5faa882200a66a793d06ee0625e15 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:09:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20Category=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Category Entity, Repository, Service 계층 구현 - CategoryFacade 유즈케이스 오케스트레이션 - Category Admin API (CRUD) 구현 - Category 사용자 API (목록/상세 조회) 구현 - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../application/category/CategoryCommand.java | 13 + .../category/CategoryDetailInfo.java | 29 ++ .../application/category/CategoryFacade.java | 69 ++++ .../application/category/CategoryInfo.java | 33 ++ .../com/loopers/domain/category/Category.java | 79 ++++ .../domain/category/CategoryRepository.java | 21 ++ .../domain/category/CategoryService.java | 90 +++++ .../category/CategoryEntity.java | 61 +++ .../category/CategoryJpaRepository.java | 14 + .../category/CategoryRepositoryImpl.java | 72 ++++ .../api/category/CategoryAdminV1ApiSpec.java | 34 ++ .../category/CategoryAdminV1Controller.java | 62 ++++ .../api/category/CategoryAdminV1Dto.java | 48 +++ .../api/category/CategoryV1ApiSpec.java | 17 + .../api/category/CategoryV1Controller.java | 29 ++ .../api/category/CategoryV1Dto.java | 29 ++ .../domain/category/CategoryServiceTest.java | 347 ++++++++++++++++++ .../loopers/domain/category/CategoryTest.java | 170 +++++++++ .../category/CategoryAdminV1ApiE2ETest.java | 269 ++++++++++++++ .../api/category/CategoryV1ApiE2ETest.java | 119 ++++++ http/category-admin-v1.http | 32 ++ http/category-v1.http | 3 + 22 files changed, 1640 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/category/CategoryCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/category/CategoryDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/category/CategoryFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/category/CategoryInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryV1ApiE2ETest.java create mode 100644 http/category-admin-v1.http create mode 100644 http/category-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryCommand.java new file mode 100644 index 000000000..15c7d7a50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryCommand.java @@ -0,0 +1,13 @@ +package com.loopers.application.category; + +public class CategoryCommand { + + public record Create( + String name, + Long parentId + ) {} + + public record Update( + String name + ) {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryDetailInfo.java new file mode 100644 index 000000000..8344a7a7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryDetailInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.category; + +import com.loopers.domain.category.Category; + +import java.time.LocalDateTime; + +public record CategoryDetailInfo( + Long id, + Long parentId, + String name, + String path, + Integer depth, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { + public static CategoryDetailInfo from(Category category) { + return new CategoryDetailInfo( + category.getId(), + category.getParentId(), + category.getName(), + category.getPath(), + category.getDepth(), + category.getCreatedAt(), + category.getUpdatedAt(), + category.getDeletedAt() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryFacade.java new file mode 100644 index 000000000..9225fd48e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryFacade.java @@ -0,0 +1,69 @@ +package com.loopers.application.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryService; +import com.loopers.support.auth.AdminValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class CategoryFacade { + + private final CategoryService categoryService; + private final AdminValidator adminValidator; + + @Transactional(readOnly = true) + public List getCategoriesHierarchy() { + List allCategories = categoryService.getAllActiveCategories(); + return buildHierarchy(allCategories); + } + + @Transactional + public CategoryDetailInfo createCategory(String ldap, CategoryCommand.Create command) { + adminValidator.validate(ldap); + Category category = categoryService.createCategory(command.name(), command.parentId()); + return CategoryDetailInfo.from(category); + } + + @Transactional + public CategoryDetailInfo updateCategory(String ldap, Long categoryId, CategoryCommand.Update command) { + adminValidator.validate(ldap); + Category category = categoryService.updateCategory(categoryId, command.name()); + return CategoryDetailInfo.from(category); + } + + @Transactional + public void deleteCategory(String ldap, Long categoryId) { + adminValidator.validate(ldap); + categoryService.deleteCategory(categoryId); + } + + private List buildHierarchy(List categories) { + Map> childrenMap = categories.stream() + .filter(c -> c.getParentId() != null) + .collect(Collectors.groupingBy(Category::getParentId)); + + List rootCategories = categories.stream() + .filter(Category::isRoot) + .toList(); + + return rootCategories.stream() + .map(root -> buildCategoryInfo(root, childrenMap)) + .toList(); + } + + private CategoryInfo buildCategoryInfo(Category category, Map> childrenMap) { + List children = childrenMap.getOrDefault(category.getId(), List.of()); + List childInfos = children.stream() + .map(child -> buildCategoryInfo(child, childrenMap)) + .toList(); + return CategoryInfo.withChildren(category, childInfos); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryInfo.java new file mode 100644 index 000000000..0a2d4919a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.category; + +import com.loopers.domain.category.Category; + +import java.util.List; + +public record CategoryInfo( + Long id, + Long parentId, + String name, + Integer depth, + List children +) { + public static CategoryInfo from(Category category) { + return new CategoryInfo( + category.getId(), + category.getParentId(), + category.getName(), + category.getDepth(), + List.of() + ); + } + + public static CategoryInfo withChildren(Category category, List children) { + return new CategoryInfo( + category.getId(), + category.getParentId(), + category.getName(), + category.getDepth(), + children + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java new file mode 100644 index 000000000..33826a2c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java @@ -0,0 +1,79 @@ +package com.loopers.domain.category; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class Category { + + private Long id; + private Long parentId; + private String name; + private String path; + private Integer depth; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + + public Category(String name) { + validateName(name); + this.name = name; + this.parentId = null; + this.depth = 0; + } + + public Category(String name, Long parentId, String parentPath, Integer parentDepth) { + validateName(name); + this.name = name; + this.parentId = parentId; + this.depth = parentDepth + 1; + } + + public Category(Long id, Long parentId, String name, String path, Integer depth, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.parentId = parentId; + this.name = name; + this.path = path; + this.depth = depth; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public boolean isRoot() { + return parentId == null; + } + + public boolean isDeleted() { + return deletedAt != null; + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + public void update(String name) { + validateName(name); + this.name = name; + } + + public void assignPath(Long savedId) { + if (isRoot()) { + this.path = String.valueOf(savedId); + } + } + + public void assignChildPath(String parentPath, Long savedId) { + this.path = parentPath + "/" + savedId; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카테고리명은 필수입니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java new file mode 100644 index 000000000..bff06db9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.category; + +import java.util.List; +import java.util.Optional; + +public interface CategoryRepository { + + Optional findById(Long id); + + List findAllActive(); + + List findAllActiveByParentId(Long parentId); + + List findAllActiveChildrenByPath(String pathPrefix); + + Category save(Category category); + + void delete(Long id); + + boolean existsById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryService.java new file mode 100644 index 000000000..22dd71323 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryService.java @@ -0,0 +1,90 @@ +package com.loopers.domain.category; + +import com.loopers.domain.product.ProductService; +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + private final ProductService productService; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Category getCategory(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "카테고리를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Category getActiveCategory(Long categoryId) { + Category category = getCategory(categoryId); + if (category.isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 카테고리입니다."); + } + return category; + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public List getAllActiveCategories() { + return categoryRepository.findAllActive(); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Category createCategory(String name, Long parentId) { + if (parentId == null) { + Category category = new Category(name); + Category saved = categoryRepository.save(category); + saved.assignPath(saved.getId()); + return categoryRepository.save(saved); + } + + Category parent = getActiveCategory(parentId); + Category category = new Category(name, parentId, parent.getPath(), parent.getDepth()); + Category saved = categoryRepository.save(category); + saved.assignChildPath(parent.getPath(), saved.getId()); + return categoryRepository.save(saved); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Category updateCategory(Long categoryId, String name) { + Category category = getCategory(categoryId); + category.update(name); + return categoryRepository.save(category); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void deleteCategory(Long categoryId) { + Category category = getCategory(categoryId); + + // 삭제할 카테고리 ID 수집 (자신 + 하위) + List categoryIdsToDelete = new ArrayList<>(); + categoryIdsToDelete.add(categoryId); + + // 하위 카테고리도 함께 삭제 (CAT-012) + List children = categoryRepository.findAllActiveChildrenByPath(category.getPath() + "/"); + categoryIdsToDelete.addAll(children.stream().map(Category::getId).toList()); + + // 연관 상품 삭제 + productService.deleteProductsByCategoryIds(categoryIdsToDelete); + + for (Category child : children) { + categoryRepository.delete(child.getId()); + } + + categoryRepository.delete(categoryId); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Category validateCategory(Long categoryId) { + return getActiveCategory(categoryId); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java new file mode 100644 index 000000000..24b1c66d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.category; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.category.Category; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +@Entity +@Table(name = "categories") +@SQLDelete(sql = "UPDATE categories SET deleted_at = NOW() WHERE id = ?") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CategoryEntity extends BaseEntity { + + @Column(name = "parent_id") + private Long parentId; + + @Column(name = "name", nullable = false, length = 20) + private String name; + + @Column(name = "path", length = 255) + private String path; + + @Column(name = "depth") + private Integer depth; + + public static CategoryEntity from(Category category) { + CategoryEntity entity = new CategoryEntity(); + entity.parentId = category.getParentId(); + entity.name = category.getName(); + entity.path = category.getPath(); + entity.depth = category.getDepth(); + return entity; + } + + public Category toDomain() { + return new Category( + getId(), + parentId, + name, + path, + depth, + getCreatedAt() != null ? getCreatedAt().toLocalDateTime() : null, + getUpdatedAt() != null ? getUpdatedAt().toLocalDateTime() : null, + getDeletedAt() != null ? getDeletedAt().toLocalDateTime() : null + ); + } + + public void update(String name) { + this.name = name; + } + + public void assignPath(String path) { + this.path = path; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java new file mode 100644 index 000000000..77ce3efd7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.category; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CategoryJpaRepository extends JpaRepository { + + List findByDeletedAtIsNull(); + + List findByParentIdAndDeletedAtIsNull(Long parentId); + + List findByPathStartingWithAndDeletedAtIsNull(String pathPrefix); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java new file mode 100644 index 000000000..85fb4394d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class CategoryRepositoryImpl implements CategoryRepository { + + private final CategoryJpaRepository categoryJpaRepository; + + @Override + public Optional findById(Long id) { + return categoryJpaRepository.findById(id) + .map(CategoryEntity::toDomain); + } + + @Override + public List findAllActive() { + return categoryJpaRepository.findByDeletedAtIsNull().stream() + .map(CategoryEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveByParentId(Long parentId) { + return categoryJpaRepository.findByParentIdAndDeletedAtIsNull(parentId).stream() + .map(CategoryEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveChildrenByPath(String pathPrefix) { + return categoryJpaRepository.findByPathStartingWithAndDeletedAtIsNull(pathPrefix).stream() + .map(CategoryEntity::toDomain) + .toList(); + } + + @Override + public Category save(Category category) { + CategoryEntity entity; + if (category.getId() != null) { + entity = categoryJpaRepository.findById(category.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "카테고리를 찾을 수 없습니다.")); + entity.update(category.getName()); + if (category.getPath() != null && entity.getPath() == null) { + entity.assignPath(category.getPath()); + } + } else { + entity = CategoryEntity.from(category); + } + CategoryEntity saved = categoryJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public void delete(Long id) { + categoryJpaRepository.deleteById(id); + } + + @Override + public boolean existsById(Long id) { + return categoryJpaRepository.existsById(id); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiSpec.java new file mode 100644 index 000000000..595f4f4b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiSpec.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Category Admin V1 API", description = "카테고리 관리자 API 입니다.") +public interface CategoryAdminV1ApiSpec { + + @Operation( + summary = "카테고리 등록 (Admin)", + description = "새로운 카테고리를 등록합니다." + ) + ApiResponse createCategory( + String ldap, + CategoryAdminV1Dto.CreateCategoryRequest request + ); + + @Operation( + summary = "카테고리 수정 (Admin)", + description = "카테고리 정보를 수정합니다." + ) + ApiResponse updateCategory( + String ldap, + Long categoryId, + CategoryAdminV1Dto.UpdateCategoryRequest request + ); + + @Operation( + summary = "카테고리 삭제 (Admin)", + description = "카테고리를 삭제합니다. 하위 카테고리와 소속 상품도 함께 삭제됩니다." + ) + ApiResponse deleteCategory(String ldap, Long categoryId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Controller.java new file mode 100644 index 000000000..ca3596755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.application.category.CategoryCommand; +import com.loopers.application.category.CategoryDetailInfo; +import com.loopers.application.category.CategoryFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/categories") +public class CategoryAdminV1Controller implements CategoryAdminV1ApiSpec { + + private final CategoryFacade categoryFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createCategory( + @RequestHeader("X-Loopers-Ldap") String ldap, + @Valid @RequestBody CategoryAdminV1Dto.CreateCategoryRequest request + ) { + CategoryCommand.Create command = new CategoryCommand.Create(request.name(), request.parentId()); + CategoryDetailInfo info = categoryFacade.createCategory(ldap, command); + CategoryAdminV1Dto.CategoryDetailResponse response = CategoryAdminV1Dto.CategoryDetailResponse.from(info); + return ApiResponse.success(response); + } + + @PutMapping("/{categoryId}") + @Override + public ApiResponse updateCategory( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long categoryId, + @Valid @RequestBody CategoryAdminV1Dto.UpdateCategoryRequest request + ) { + CategoryCommand.Update command = new CategoryCommand.Update(request.name()); + CategoryDetailInfo info = categoryFacade.updateCategory(ldap, categoryId, command); + CategoryAdminV1Dto.CategoryDetailResponse response = CategoryAdminV1Dto.CategoryDetailResponse.from(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{categoryId}") + @Override + public ApiResponse deleteCategory( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long categoryId + ) { + categoryFacade.deleteCategory(ldap, categoryId); + return ApiResponse.success(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Dto.java new file mode 100644 index 000000000..63255635c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryAdminV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.application.category.CategoryDetailInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public class CategoryAdminV1Dto { + + public record CreateCategoryRequest( + @NotBlank(message = "카테고리명은 비어있을 수 없습니다.") + @Size(max = 20, message = "카테고리명은 20자를 초과할 수 없습니다.") + String name, + + Long parentId + ) {} + + public record UpdateCategoryRequest( + @NotBlank(message = "카테고리명은 비어있을 수 없습니다.") + @Size(max = 20, message = "카테고리명은 20자를 초과할 수 없습니다.") + String name + ) {} + + public record CategoryDetailResponse( + Long id, + Long parentId, + String name, + String path, + Integer depth, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + public static CategoryDetailResponse from(CategoryDetailInfo info) { + return new CategoryDetailResponse( + info.id(), + info.parentId(), + info.name(), + info.path(), + info.depth(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1ApiSpec.java new file mode 100644 index 000000000..f52b02fa1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Category V1 API", description = "카테고리 API 입니다.") +public interface CategoryV1ApiSpec { + + @Operation( + summary = "카테고리 목록 조회", + description = "계층 구조로 카테고리 목록을 조회합니다." + ) + ApiResponse> getCategories(); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Controller.java new file mode 100644 index 000000000..ef87cc579 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Controller.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.application.category.CategoryFacade; +import com.loopers.application.category.CategoryInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/categories") +public class CategoryV1Controller implements CategoryV1ApiSpec { + + private final CategoryFacade categoryFacade; + + @GetMapping + @Override + public ApiResponse> getCategories() { + List infos = categoryFacade.getCategoriesHierarchy(); + List response = infos.stream() + .map(CategoryV1Dto.CategoryResponse::from) + .toList(); + return ApiResponse.success(response); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Dto.java new file mode 100644 index 000000000..cb2bfc466 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryV1Dto.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.application.category.CategoryInfo; + +import java.util.List; + +public class CategoryV1Dto { + + public record CategoryResponse( + Long id, + Long parentId, + String name, + Integer depth, + List children + ) { + public static CategoryResponse from(CategoryInfo info) { + List childResponses = info.children().stream() + .map(CategoryResponse::from) + .toList(); + return new CategoryResponse( + info.id(), + info.parentId(), + info.name(), + info.depth(), + childResponses + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryServiceTest.java new file mode 100644 index 000000000..8eba024c2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryServiceTest.java @@ -0,0 +1,347 @@ +package com.loopers.domain.category; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("CategoryService 통합 테스트") +class CategoryServiceTest { + + @Autowired + private CategoryService categoryService; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getCategory") + class GetCategory { + + @Test + @DisplayName("존재하는 카테고리를 조회하면 Category를 반환한다") + void returnsCategory_whenCategoryExists() { + // Arrange + Category saved = categoryRepository.save(new Category("전자제품")); + + // Act + Category result = categoryService.getCategory(saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getName()).isEqualTo("전자제품") + ); + } + + @Test + @DisplayName("존재하지 않는 카테고리를 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenCategoryNotExists() { + // Act & Assert + assertThatThrownBy(() -> categoryService.getCategory(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getActiveCategory") + class GetActiveCategory { + + @Test + @DisplayName("활성 카테고리를 조회하면 Category를 반환한다") + void returnsCategory_whenCategoryIsActive() { + // Arrange + Category saved = categoryRepository.save(new Category("전자제품")); + + // Act + Category result = categoryService.getActiveCategory(saved.getId()); + + // Assert + assertThat(result.getName()).isEqualTo("전자제품"); + } + + @Test + @DisplayName("삭제된 카테고리를 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenCategoryIsDeleted() { + // Arrange + Category category = new Category("전자제품"); + Category saved = categoryRepository.save(category); + categoryService.deleteCategory(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> categoryService.getActiveCategory(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getAllActiveCategories") + class GetAllActiveCategories { + + @Test + @DisplayName("삭제되지 않은 카테고리만 조회한다") + void returnsOnlyActiveCategories() { + // Arrange + categoryRepository.save(new Category("전자제품")); + categoryRepository.save(new Category("의류")); + Category toDelete = categoryRepository.save(new Category("식품")); + categoryService.deleteCategory(toDelete.getId()); + + // Act + List result = categoryService.getAllActiveCategories(); + + // Assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result).extracting(Category::getName) + .containsExactlyInAnyOrder("전자제품", "의류") + ); + } + } + + @Nested + @DisplayName("createCategory") + class CreateCategory { + + @Test + @DisplayName("루트 카테고리를 정상적으로 생성한다") + void createsRootCategory() { + // Act + Category result = categoryService.createCategory("전자제품", null); + + // Assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("전자제품"), + () -> assertThat(result.isRoot()).isTrue(), + () -> assertThat(result.getDepth()).isEqualTo(0), + () -> assertThat(result.getPath()).isEqualTo(String.valueOf(result.getId())) + ); + } + + @Test + @DisplayName("하위 카테고리를 정상적으로 생성한다") + void createsChildCategory() { + // Arrange + Category parent = categoryService.createCategory("전자제품", null); + + // Act + Category result = categoryService.createCategory("휴대폰", parent.getId()); + + // Assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("휴대폰"), + () -> assertThat(result.getParentId()).isEqualTo(parent.getId()), + () -> assertThat(result.getDepth()).isEqualTo(1), + () -> assertThat(result.getPath()).isEqualTo(parent.getPath() + "/" + result.getId()) + ); + } + + @Test + @DisplayName("존재하지 않는 부모 카테고리로 생성하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenParentNotExists() { + // Act & Assert + assertThatThrownBy(() -> categoryService.createCategory("휴대폰", 999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("deleteCategory") + class DeleteCategory { + + @Test + @DisplayName("카테고리를 삭제하면 Soft Delete 된다") + void deletesCategory() { + // Arrange + Category saved = categoryRepository.save(new Category("전자제품")); + + // Act + categoryService.deleteCategory(saved.getId()); + + // Assert + Category deleted = categoryService.getCategory(saved.getId()); + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 카테고리를 삭제하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenCategoryNotExists() { + // Act & Assert + assertThatThrownBy(() -> categoryService.deleteCategory(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("카테고리 삭제 시 하위 카테고리도 함께 삭제된다") + void deletesChildCategories_whenParentDeleted() { + // Arrange + Category parent = categoryService.createCategory("전자제품", null); + Category child = categoryService.createCategory("휴대폰", parent.getId()); + Category grandChild = categoryService.createCategory("스마트폰", child.getId()); + + // Act + categoryService.deleteCategory(parent.getId()); + + // Assert + assertAll( + () -> assertThat(categoryService.getCategory(parent.getId()).isDeleted()).isTrue(), + () -> assertThat(categoryService.getCategory(child.getId()).isDeleted()).isTrue(), + () -> assertThat(categoryService.getCategory(grandChild.getId()).isDeleted()).isTrue() + ); + } + + @Test + @DisplayName("카테고리 삭제 시 연관된 상품도 함께 Soft Delete 된다") + void deletesRelatedProducts_whenCategoryDeleted() { + // Arrange + Brand brand = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + Category category = categoryService.createCategory("스포츠", null); + Product product1 = productService.createProduct("나이키 신발", brand.getId(), category.getId(), 100000L); + Product product2 = productService.createProduct("나이키 가방", brand.getId(), category.getId(), 50000L); + + // Act + categoryService.deleteCategory(category.getId()); + + // Assert + Product deletedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); + Product deletedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); + assertAll( + () -> assertThat(deletedProduct1.isDeleted()).isTrue(), + () -> assertThat(deletedProduct2.isDeleted()).isTrue() + ); + } + + @Test + @DisplayName("부모 카테고리 삭제 시 하위 카테고리의 상품도 함께 Soft Delete 된다") + void deletesChildCategoryProducts_whenParentCategoryDeleted() { + // Arrange + Brand brand = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + Category parent = categoryService.createCategory("전자제품", null); + Category child = categoryService.createCategory("휴대폰", parent.getId()); + Category grandChild = categoryService.createCategory("스마트폰", child.getId()); + Product parentProduct = productService.createProduct("전자제품1", brand.getId(), parent.getId(), 100000L); + Product childProduct = productService.createProduct("휴대폰1", brand.getId(), child.getId(), 200000L); + Product grandChildProduct = productService.createProduct("스마트폰1", brand.getId(), grandChild.getId(), 300000L); + + // Act + categoryService.deleteCategory(parent.getId()); + + // Assert + Product deletedParentProduct = productRepository.findById(parentProduct.getId()).orElseThrow(); + Product deletedChildProduct = productRepository.findById(childProduct.getId()).orElseThrow(); + Product deletedGrandChildProduct = productRepository.findById(grandChildProduct.getId()).orElseThrow(); + assertAll( + () -> assertThat(deletedParentProduct.isDeleted()).isTrue(), + () -> assertThat(deletedChildProduct.isDeleted()).isTrue(), + () -> assertThat(deletedGrandChildProduct.isDeleted()).isTrue() + ); + } + + @Test + @DisplayName("연관 상품이 없는 카테고리 삭제 시 정상 동작한다") + void deletesSuccessfully_whenNoRelatedProducts() { + // Arrange + Category category = categoryService.createCategory("전자제품", null); + + // Act + categoryService.deleteCategory(category.getId()); + + // Assert + Category deleted = categoryService.getCategory(category.getId()); + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("다른 카테고리의 상품은 영향받지 않는다") + void doesNotAffectOtherCategoryProducts_whenCategoryDeleted() { + // Arrange + Brand brand = brandRepository.save(new Brand("Nike", "스포츠 브랜드", "https://logo.png")); + Category sports = categoryService.createCategory("스포츠", null); + Category electronics = categoryService.createCategory("전자제품", null); + Product sportsProduct = productService.createProduct("스포츠용품", brand.getId(), sports.getId(), 100000L); + Product electronicsProduct = productService.createProduct("전자제품1", brand.getId(), electronics.getId(), 200000L); + + // Act + categoryService.deleteCategory(sports.getId()); + + // Assert + Product deletedSportsProduct = productRepository.findById(sportsProduct.getId()).orElseThrow(); + Product activeElectronicsProduct = productRepository.findById(electronicsProduct.getId()).orElseThrow(); + assertAll( + () -> assertThat(deletedSportsProduct.isDeleted()).isTrue(), + () -> assertThat(activeElectronicsProduct.isDeleted()).isFalse() + ); + } + } + + @Nested + @DisplayName("validateCategory") + class ValidateCategory { + + @Test + @DisplayName("존재하고 활성인 카테고리를 검증하면 Category를 반환한다") + void returnsCategory_whenCategoryIsValid() { + // Arrange + Category saved = categoryRepository.save(new Category("전자제품")); + + // Act + Category result = categoryService.validateCategory(saved.getId()); + + // Assert + assertThat(result.getName()).isEqualTo("전자제품"); + } + + @Test + @DisplayName("삭제된 카테고리를 검증하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenCategoryIsDeleted() { + // Arrange + Category saved = categoryRepository.save(new Category("전자제품")); + categoryService.deleteCategory(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> categoryService.validateCategory(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryTest.java new file mode 100644 index 000000000..3b27301e9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/category/CategoryTest.java @@ -0,0 +1,170 @@ +package com.loopers.domain.category; + +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 java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Category 도메인 단위 테스트") +class CategoryTest { + + @Nested + @DisplayName("생성자") + class Constructor { + + @Test + @DisplayName("정상적인 값으로 루트 카테고리를 생성한다") + void createsRootCategory_whenValidValues() { + // Act + Category category = new Category("전자제품"); + + // Assert + assertAll( + () -> assertThat(category.getName()).isEqualTo("전자제품"), + () -> assertThat(category.getParentId()).isNull(), + () -> assertThat(category.getDepth()).isEqualTo(0), + () -> assertThat(category.isRoot()).isTrue() + ); + } + + @Test + @DisplayName("부모 카테고리를 지정하여 하위 카테고리를 생성한다") + void createsChildCategory_whenParentProvided() { + // Arrange + Long parentId = 1L; + String parentPath = "1"; + Integer parentDepth = 0; + + // Act + Category category = new Category("휴대폰", parentId, parentPath, parentDepth); + + // Assert + assertAll( + () -> assertThat(category.getName()).isEqualTo("휴대폰"), + () -> assertThat(category.getParentId()).isEqualTo(1L), + () -> assertThat(category.getDepth()).isEqualTo(1), + () -> assertThat(category.isRoot()).isFalse() + ); + } + + @Test + @DisplayName("name이 null이면 예외가 발생한다") + void throwsException_whenNameIsNull() { + // Act & Assert + assertThatThrownBy(() -> new Category(null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name이 빈 문자열이면 예외가 발생한다") + void throwsException_whenNameIsEmpty() { + // Act & Assert + assertThatThrownBy(() -> new Category("")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name이 공백 문자열이면 예외가 발생한다") + void throwsException_whenNameIsBlank() { + // Act & Assert + assertThatThrownBy(() -> new Category(" ")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("isRoot") + class IsRoot { + + @Test + @DisplayName("parentId가 null이면 true를 반환한다") + void returnsTrue_whenParentIdIsNull() { + // Arrange + Category category = new Category("전자제품"); + + // Act & Assert + assertThat(category.isRoot()).isTrue(); + } + + @Test + @DisplayName("parentId가 존재하면 false를 반환한다") + void returnsFalse_whenParentIdExists() { + // Arrange + Category category = new Category("휴대폰", 1L, "1", 0); + + // Act & Assert + assertThat(category.isRoot()).isFalse(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("삭제하면 deletedAt이 설정된다") + void setsDeletedAt_whenDeleted() { + // Arrange + Category category = new Category("전자제품"); + + // Act + category.delete(); + + // Assert + assertAll( + () -> assertThat(category.isDeleted()).isTrue(), + () -> assertThat(category.getDeletedAt()).isNotNull() + ); + } + + @Test + @DisplayName("삭제되지 않은 카테고리는 isDeleted가 false다") + void returnsFalse_whenNotDeleted() { + // Arrange + Category category = new Category("전자제품"); + + // Act & Assert + assertThat(category.isDeleted()).isFalse(); + } + } + + @Nested + @DisplayName("update") + class Update { + + @Test + @DisplayName("카테고리명을 수정한다") + void updatesName() { + // Arrange + Category category = new Category(1L, null, "전자제품", "1", 0, null, null, null); + + // Act + category.update("가전제품"); + + // Assert + assertThat(category.getName()).isEqualTo("가전제품"); + } + + @Test + @DisplayName("수정 시 name이 null이면 예외가 발생한다") + void throwsException_whenUpdateNameIsNull() { + // Arrange + Category category = new Category(1L, null, "전자제품", "1", 0, null, null, null); + + // Act & Assert + assertThatThrownBy(() -> category.update(null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiE2ETest.java new file mode 100644 index 000000000..15d4a7cf1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryAdminV1ApiE2ETest.java @@ -0,0 +1,269 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryService; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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) +@DisplayName("Category Admin V1 API E2E 테스트") +class CategoryAdminV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/admin/categories"; + private static final String VALID_ADMIN_LDAP = "loopers.admin"; + private static final String INVALID_ADMIN_LDAP = "invalid.ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private CategoryService categoryService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private HttpHeaders createInvalidAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", INVALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + @Nested + @DisplayName("POST /api/v1/admin/categories") + class CreateCategory { + + @Test + @DisplayName("Admin이 루트 카테고리를 등록하면 201 Created를 반환한다") + void returnsCreated_whenAdminCreatesRootCategory() { + // Arrange + CategoryAdminV1Dto.CreateCategoryRequest request = + new CategoryAdminV1Dto.CreateCategoryRequest("전자제품", null); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().name()).isEqualTo("전자제품"), + () -> assertThat(response.getBody().data().parentId()).isNull(), + () -> assertThat(response.getBody().data().depth()).isEqualTo(0) + ); + } + + @Test + @DisplayName("Admin이 하위 카테고리를 등록하면 201 Created를 반환한다") + void returnsCreated_whenAdminCreatesChildCategory() { + // Arrange + Category parent = categoryService.createCategory("전자제품", null); + CategoryAdminV1Dto.CreateCategoryRequest request = + new CategoryAdminV1Dto.CreateCategoryRequest("휴대폰", parent.getId()); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().parentId()).isEqualTo(parent.getId()), + () -> assertThat(response.getBody().data().depth()).isEqualTo(1) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 등록하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminCreates() { + // Arrange + CategoryAdminV1Dto.CreateCategoryRequest request = + new CategoryAdminV1Dto.CreateCategoryRequest("전자제품", null); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @Nested + @DisplayName("PUT /api/v1/admin/categories/{categoryId}") + class UpdateCategory { + + @Test + @DisplayName("Admin이 카테고리를 수정하면 200 OK를 반환한다") + void returnsOk_whenAdminUpdates() { + // Arrange + Category saved = categoryService.createCategory("전자제품", null); + CategoryAdminV1Dto.UpdateCategoryRequest request = + new CategoryAdminV1Dto.UpdateCategoryRequest("가전제품"); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.PUT, + new HttpEntity<>(request, createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("가전제품") + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 수정하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminUpdates() { + // Arrange + Category saved = categoryService.createCategory("전자제품", null); + CategoryAdminV1Dto.UpdateCategoryRequest request = + new CategoryAdminV1Dto.UpdateCategoryRequest("가전제품"); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.PUT, + new HttpEntity<>(request, createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @Nested + @DisplayName("DELETE /api/v1/admin/categories/{categoryId}") + class DeleteCategory { + + @Test + @DisplayName("Admin이 카테고리를 삭제하면 200 OK를 반환한다") + void returnsOk_whenAdminDeletes() { + // Arrange + Category saved = categoryService.createCategory("전자제품", null); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 삭제하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminDeletes() { + // Arrange + Category saved = categoryService.createCategory("전자제품", null); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("하위 카테고리도 함께 삭제된다") + void deletesChildCategories() { + // Arrange + Category parent = categoryService.createCategory("전자제품", null); + Category child = categoryService.createCategory("휴대폰", parent.getId()); + + // Act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange( + ENDPOINT + "/" + parent.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + Category deletedParent = categoryService.getCategory(parent.getId()); + Category deletedChild = categoryService.getCategory(child.getId()); + assertAll( + () -> assertThat(deletedParent.isDeleted()).isTrue(), + () -> assertThat(deletedChild.isDeleted()).isTrue() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryV1ApiE2ETest.java new file mode 100644 index 000000000..f35686fe5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/category/CategoryV1ApiE2ETest.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.domain.category.CategoryService; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +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) +@DisplayName("Category V1 API E2E 테스트") +class CategoryV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/categories"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private CategoryService categoryService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/categories") + class GetCategories { + + @Test + @DisplayName("카테고리 목록을 계층 구조로 조회한다") + void returnsHierarchicalCategories() { + // Arrange + var parent = categoryService.createCategory("전자제품", null); + categoryService.createCategory("휴대폰", parent.getId()); + categoryService.createCategory("의류", null); + + // Act + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2), + () -> assertThat(response.getBody().data()) + .extracting(CategoryV1Dto.CategoryResponse::name) + .containsExactlyInAnyOrder("전자제품", "의류") + ); + } + + @Test + @DisplayName("삭제된 카테고리는 목록에서 제외된다") + void excludesDeletedCategories() { + // Arrange + var category1 = categoryService.createCategory("전자제품", null); + var category2 = categoryService.createCategory("의류", null); + categoryService.deleteCategory(category2.getId()); + + // Act + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, responseType); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).name()).isEqualTo("전자제품") + ); + } + + @Test + @DisplayName("하위 카테고리가 children에 포함된다") + void includesChildrenInHierarchy() { + // Arrange + var parent = categoryService.createCategory("전자제품", null); + categoryService.createCategory("휴대폰", parent.getId()); + categoryService.createCategory("노트북", parent.getId()); + + // Act + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, responseType); + + // Assert + var rootCategory = response.getBody().data().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(rootCategory.name()).isEqualTo("전자제품"), + () -> assertThat(rootCategory.children()).hasSize(2), + () -> assertThat(rootCategory.children()) + .extracting(CategoryV1Dto.CategoryResponse::name) + .containsExactlyInAnyOrder("휴대폰", "노트북") + ); + } + } +} \ No newline at end of file diff --git a/http/category-admin-v1.http b/http/category-admin-v1.http new file mode 100644 index 000000000..32372079e --- /dev/null +++ b/http/category-admin-v1.http @@ -0,0 +1,32 @@ +### 루트 카테고리 등록 (Admin) +POST http://localhost:8080/api/v1/admin/categories +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "전자제품", + "parentId": null +} + +### 하위 카테고리 등록 (Admin) +POST http://localhost:8080/api/v1/admin/categories +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "휴대폰", + "parentId": 1 +} + +### 카테고리 수정 (Admin) +PUT http://localhost:8080/api/v1/admin/categories/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "가전제품" +} + +### 카테고리 삭제 (Admin) +DELETE http://localhost:8080/api/v1/admin/categories/1 +X-Loopers-Ldap: loopers.admin \ No newline at end of file diff --git a/http/category-v1.http b/http/category-v1.http new file mode 100644 index 000000000..11402ab51 --- /dev/null +++ b/http/category-v1.http @@ -0,0 +1,3 @@ +### 카테고리 목록 조회 (계층 구조) +GET http://localhost:8080/api/v1/categories +Accept: application/json \ No newline at end of file From aad4160f8e18ad1d90efecf02d8bf5313faa835f Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:10:00 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product, ProductOption, ProductImage Entity 구현 - ProductValidator를 통한 도메인 검증 로직 캡슐화 - ProductStatus (ACTIVE, STOP, SOLDOUT) 상태 관리 - 재고 0일 때 SOLDOUT 자동 전환 로직 - LIKES_DESC 정렬 지원 (좋아요 수 내림차순) - Product Admin API (CRUD, 상태/할인 관리) - 상품 검색 (키워드, 카테고리 필터링) - QueryDSL 기반 동적 쿼리 구현 - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../product/ProductAdminDetailInfo.java | 46 ++ .../application/product/ProductCommand.java | 23 + .../product/ProductDetailInfo.java | 40 + .../application/product/ProductFacade.java | 97 +++ .../application/product/ProductImageInfo.java | 20 + .../application/product/ProductInfo.java | 34 + .../product/ProductOptionInfo.java | 21 + .../loopers/domain/product/DiscountType.java | 6 + .../com/loopers/domain/product/ImageType.java | 7 + .../com/loopers/domain/product/Product.java | 228 ++++++ .../loopers/domain/product/ProductImage.java | 38 + .../domain/product/ProductImageValidator.java | 23 + .../loopers/domain/product/ProductOption.java | 65 ++ .../product/ProductOptionValidator.java | 19 + .../domain/product/ProductRepository.java | 32 + .../domain/product/ProductService.java | 148 ++++ .../domain/product/ProductSortType.java | 7 + .../loopers/domain/product/ProductStatus.java | 7 + .../domain/product/ProductValidator.java | 69 ++ .../infrastructure/product/ProductEntity.java | 203 +++++ .../ProductFullTextIndexInitializer.java | 32 + .../product/ProductImageEntity.java | 91 +++ .../product/ProductJpaRepository.java | 39 + .../product/ProductJpaRepositoryCustom.java | 10 + .../ProductJpaRepositoryCustomImpl.java | 71 ++ .../product/ProductOptionEntity.java | 71 ++ .../product/ProductRepositoryImpl.java | 108 +++ .../api/product/ProductAdminV1ApiSpec.java | 50 ++ .../api/product/ProductAdminV1Controller.java | 92 +++ .../api/product/ProductAdminV1Dto.java | 125 +++ .../api/product/ProductV1ApiSpec.java | 30 + .../api/product/ProductV1Controller.java | 45 ++ .../interfaces/api/product/ProductV1Dto.java | 122 +++ .../product/ProductFacadeTest.java | 551 +++++++++++++ .../domain/product/ProductImageTest.java | 82 ++ .../domain/product/ProductOptionTest.java | 227 ++++++ .../domain/product/ProductServiceTest.java | 747 ++++++++++++++++++ .../loopers/domain/product/ProductTest.java | 552 +++++++++++++ .../domain/product/ProductValidatorTest.java | 238 ++++++ .../api/product/ProductAdminV1ApiE2ETest.java | 694 ++++++++++++++++ .../api/product/ProductV1ApiE2ETest.java | 343 ++++++++ http/product-admin-v1.http | 39 + http/product-v1.http | 43 + 43 files changed, 5535 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java 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/ProductImageInfo.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/ProductOptionInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/DiscountType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.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/ProductImage.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionValidator.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/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductFullTextIndexInitializer.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.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/ProductJpaRepositoryCustom.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustomImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.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/ProductAdminV1ApiSpec.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/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductOptionTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.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/domain/product/ProductValidatorTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java create mode 100644 http/product-admin-v1.http create mode 100644 http/product-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminDetailInfo.java new file mode 100644 index 000000000..5af10da53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminDetailInfo.java @@ -0,0 +1,46 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record ProductAdminDetailInfo( + Long id, + String name, + String productCode, + Long brandId, + Long categoryId, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + List options, + List images, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { + public static ProductAdminDetailInfo from(Product product) { + return new ProductAdminDetailInfo( + product.getId(), + product.getName(), + product.getProductCode(), + product.getBrandId(), + product.getCategoryId(), + product.getBasePrice(), + product.calculateDiscountedPrice(), + product.getStatus(), + product.getDiscount(), + product.getDiscountType(), + product.getOptions().stream().map(ProductOptionInfo::from).toList(), + product.getImages().stream().map(ProductImageInfo::from).toList(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } +} 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..a890a2b1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCommand.java @@ -0,0 +1,23 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.ProductStatus; + +public class ProductCommand { + + public record Create( + String name, + Long brandId, + Long categoryId, + Long basePrice + ) {} + + public record Update( + String name, + Long categoryId, + Long basePrice, + Long discount, + DiscountType discountType, + ProductStatus status + ) {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..2777ee2b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; + +import java.util.List; + +public record ProductDetailInfo( + Long id, + String name, + String productCode, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + BrandInfo brand, + Long likeCount, + List options, + List images +) { + public static ProductDetailInfo from(Product product, BrandInfo brand, Long likeCount) { + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getProductCode(), + product.getBasePrice(), + product.calculateDiscountedPrice(), + product.getStatus(), + product.getDiscount(), + product.getDiscountType(), + brand, + likeCount, + product.getOptions().stream().map(ProductOptionInfo::from).toList(), + product.getImages().stream().map(ProductImageInfo::from).toList() + ); + } +} \ No newline at end of file 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..967316396 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,97 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.category.CategoryService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.auth.AdminValidator; +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; + +@Component +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final CategoryService categoryService; + private final AdminValidator adminValidator; + + @Transactional(readOnly = true) + public ProductDetailInfo getProduct(Long productId) { + Product product = productService.getActiveProduct(productId); + Brand brand = brandService.getActiveBrand(product.getBrandId()); + return ProductDetailInfo.from(product, BrandInfo.from(brand), product.getLikeCount()); + } + + @Transactional(readOnly = true) + public Page getProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable) { + Page products = productService.getProducts(categoryId, keyword, sort, pageable); + + // 1. 모든 brandId 수집 (중복 제거) + List brandIds = products.getContent().stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 2. 한 번의 쿼리로 모든 브랜드 조회 + Map brandMap = brandService.getActiveBrandsByIds(brandIds); + + // 3. 매핑 + return products.map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + BrandInfo brandInfo = brand != null ? BrandInfo.from(brand) : null; + return ProductInfo.from(product, brandInfo, product.getLikeCount()); + }); + } + + @Transactional(readOnly = true) + public ProductAdminDetailInfo getProductDetail(String ldap, Long productId) { + adminValidator.validate(ldap); + Product product = productService.getProduct(productId); + return ProductAdminDetailInfo.from(product); + } + + @Transactional + public ProductAdminDetailInfo createProduct(String ldap, ProductCommand.Create command) { + adminValidator.validate(ldap); + brandService.validateBrand(command.brandId()); + categoryService.validateCategory(command.categoryId()); + Product product = productService.createProduct( + command.name(), command.brandId(), command.categoryId(), command.basePrice() + ); + return ProductAdminDetailInfo.from(product); + } + + @Transactional + public ProductAdminDetailInfo updateProduct(String ldap, Long productId, ProductCommand.Update command) { + adminValidator.validate(ldap); + Product product = productService.updateProduct( + productId, command.name(), command.categoryId(), command.basePrice(), + command.discount(), command.discountType(), command.status() + ); + return ProductAdminDetailInfo.from(product); + } + + @Transactional + public void deleteProduct(String ldap, Long productId) { + adminValidator.validate(ldap); + productService.deleteProduct(productId); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(String ldap, Pageable pageable) { + adminValidator.validate(ldap); + return productService.getProductsForAdmin(pageable) + .map(ProductAdminDetailInfo::from); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductImageInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductImageInfo.java new file mode 100644 index 000000000..ffa1d30f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductImageInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImage; + +public record ProductImageInfo( + Long id, + ImageType type, + String url, + String altText +) { + public static ProductImageInfo from(ProductImage image) { + return new ProductImageInfo( + image.getId(), + image.getType(), + image.getUrl(), + image.getAltText() + ); + } +} 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..3ff26bd7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,34 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; + +public record ProductInfo( + Long id, + String name, + String productCode, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + BrandInfo brand, + Long likeCount +) { + public static ProductInfo from(Product product, BrandInfo brandInfo, Long likeCount) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getProductCode(), + product.getBasePrice(), + product.calculateDiscountedPrice(), + product.getStatus(), + product.getDiscount(), + product.getDiscountType(), + brandInfo, + likeCount + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductOptionInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductOptionInfo.java new file mode 100644 index 000000000..b700f06b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductOptionInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductOption; + +public record ProductOptionInfo( + Long id, + String optionValue, + String displayName, + Long extraPrice, + Integer stockQuantity +) { + public static ProductOptionInfo from(ProductOption option) { + return new ProductOptionInfo( + option.getId(), + option.getOptionValue(), + option.getDisplayName(), + option.getExtraPrice(), + option.getStockQuantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/DiscountType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/DiscountType.java new file mode 100644 index 000000000..e0c528739 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/DiscountType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.product; + +public enum DiscountType { + PRICE, // 정액 할인 + RATE // 정률 할인 (%) +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java new file mode 100644 index 000000000..9084486c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ImageType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum ImageType { + MAIN, + SUB, + DETAIL +} \ No newline at end of file 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..5e09e722b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,228 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +@Getter +public class Product { + + private Long id; + private String name; + private String productCode; + private Long brandId; + private Long categoryId; + private Long basePrice; + private ProductStatus status; + private Long discount; + private DiscountType discountType; + private Long likeCount; + private List options; + private List images; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + + public Product(String name, Long brandId, Long categoryId, Long basePrice) { + ProductValidator.validateName(name); + ProductValidator.validateBrandId(brandId); + ProductValidator.validateCategoryId(categoryId); + ProductValidator.validateBasePrice(basePrice); + + this.name = name; + this.brandId = brandId; + this.categoryId = categoryId; + this.basePrice = basePrice; + this.status = ProductStatus.SALE; + this.productCode = generateProductCode(); + this.likeCount = 0L; + this.options = new ArrayList<>(); + this.images = new ArrayList<>(); + } + + public Product(String name, Long brandId, Long categoryId, Long basePrice, + List options, List images) { + this(name, brandId, categoryId, basePrice); + this.options = options != null ? new ArrayList<>(options) : new ArrayList<>(); + this.images = images != null ? new ArrayList<>(images) : new ArrayList<>(); + } + + public Product(Long id, String name, String productCode, Long brandId, Long categoryId, Long basePrice, + ProductStatus status, Long discount, DiscountType discountType, Long likeCount, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.productCode = productCode; + this.brandId = brandId; + this.categoryId = categoryId; + this.basePrice = basePrice; + this.status = status; + this.discount = discount; + this.discountType = discountType; + this.likeCount = likeCount != null ? likeCount : 0L; + this.options = new ArrayList<>(); + this.images = new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public Product(Long id, String name, String productCode, Long brandId, Long categoryId, Long basePrice, + ProductStatus status, Long discount, DiscountType discountType, Long likeCount, + List options, List images, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.productCode = productCode; + this.brandId = brandId; + this.categoryId = categoryId; + this.basePrice = basePrice; + this.status = status; + this.discount = discount; + this.discountType = discountType; + this.likeCount = likeCount != null ? likeCount : 0L; + this.options = options != null ? new ArrayList<>(options) : new ArrayList<>(); + this.images = images != null ? new ArrayList<>(images) : new ArrayList<>(); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public boolean isDeleted() { + return deletedAt != null; + } + + public Long calculateDiscountedPrice() { + if (discount == null || discountType == null) { + return basePrice; + } + + long discountedPrice = switch (discountType) { + case PRICE -> basePrice - discount; + case RATE -> basePrice - (basePrice * discount / 100); + }; + + return Math.max(0L, discountedPrice); + } + + public void applyDiscount(Long discount, DiscountType discountType) { + ProductValidator.validateDiscount(discount, discountType, this.basePrice); + this.discount = discount; + this.discountType = discountType; + } + + public void removeDiscount() { + this.discount = null; + this.discountType = null; + } + + public void update(String name, Long categoryId, Long basePrice, + Long discount, DiscountType discountType, ProductStatus status) { + ProductValidator.validateName(name); + ProductValidator.validateCategoryId(categoryId); + ProductValidator.validateBasePrice(basePrice); + ProductValidator.validateDiscount(discount, discountType, basePrice); + ProductValidator.validateStatus(status); + this.name = name; + this.categoryId = categoryId; + this.basePrice = basePrice; + this.discount = discount; + this.discountType = discountType; + this.status = status; + } + + public boolean isAvailable() { + return status == ProductStatus.SALE && !isDeleted(); + } + + public void delete() { + if (deletedAt == null) { + this.deletedAt = LocalDateTime.now(); + } + } + + public void addOption(ProductOption option) { + if (option == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "옵션은 null일 수 없습니다."); + } + this.options.add(option); + } + + public void removeOption(Long optionId) { + this.options.removeIf(opt -> opt.getId().equals(optionId)); + } + + public ProductOption getOption(Long optionId) { + return options.stream() + .filter(opt -> opt.getId().equals(optionId)) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품 옵션을 찾을 수 없습니다.")); + } + + public void decreaseStock(Long optionId, int quantity) { + ProductOption option = getOption(optionId); + option.decreaseStock(quantity); + checkAndUpdateSoldoutStatus(); + } + + public void increaseStock(Long optionId, int quantity) { + ProductOption option = getOption(optionId); + option.increaseStock(quantity); + checkAndUpdateSoldoutStatus(); + } + + public int getTotalStockQuantity() { + if (options == null || options.isEmpty()) { + return 0; + } + return options.stream() + .mapToInt(ProductOption::getStockQuantity) + .sum(); + } + + public void checkAndUpdateSoldoutStatus() { + if (this.status == ProductStatus.STOP) { + return; + } + int totalStock = getTotalStockQuantity(); + if (totalStock == 0 && this.status == ProductStatus.SALE) { + this.status = ProductStatus.SOLDOUT; + } else if (totalStock > 0 && this.status == ProductStatus.SOLDOUT) { + this.status = ProductStatus.SALE; + } + } + + public void addImage(ProductImage image) { + if (image == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지는 null일 수 없습니다."); + } + this.images.add(image); + } + + public void removeImage(Long imageId) { + this.images.removeIf(img -> img.getId().equals(imageId)); + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private String generateProductCode() { + String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + int randomSuffix = ThreadLocalRandom.current().nextInt(0, 100000); + return String.format("%s-%05d", datePrefix, randomSuffix); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java new file mode 100644 index 000000000..7547d9613 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java @@ -0,0 +1,38 @@ +package com.loopers.domain.product; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ProductImage { + + private Long id; + private Long productId; + private ImageType type; + private String url; + private String altText; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public ProductImage(Long productId, ImageType type, String url, String altText) { + ProductImageValidator.validateType(type); + ProductImageValidator.validateUrl(url); + + this.productId = productId; + this.type = type; + this.url = url; + this.altText = altText; + } + + public ProductImage(Long id, Long productId, ImageType type, String url, String altText, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.productId = productId; + this.type = type; + this.url = url; + this.altText = altText; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageValidator.java new file mode 100644 index 000000000..5e33b9fbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageValidator.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public final class ProductImageValidator { + + private ProductImageValidator() { + // 인스턴스화 방지 + } + + public static void validateUrl(String url) { + if (url == null || url.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 URL은 필수입니다."); + } + } + + public static void validateType(ImageType type) { + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 타입은 필수입니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java new file mode 100644 index 000000000..229af7651 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java @@ -0,0 +1,65 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ProductOption { + + private Long id; + private Long productId; + private String optionValue; + private String displayName; + private Long extraPrice; + private Integer stockQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + + public ProductOption(Long productId, String optionValue, String displayName, Long extraPrice, Integer stockQuantity) { + ProductOptionValidator.validateOptionValue(optionValue); + ProductOptionValidator.validateStockQuantity(stockQuantity); + + this.productId = productId; + this.optionValue = optionValue; + this.displayName = displayName; + this.extraPrice = extraPrice != null ? extraPrice : 0L; + this.stockQuantity = stockQuantity; + } + + public ProductOption(Long id, Long productId, String optionValue, String displayName, Long extraPrice, Integer stockQuantity, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.productId = productId; + this.optionValue = optionValue; + this.displayName = displayName; + this.extraPrice = extraPrice; + this.stockQuantity = stockQuantity; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (this.stockQuantity < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stockQuantity -= quantity; + } + + public void increaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "증가 수량은 1 이상이어야 합니다."); + } + if ((long) this.stockQuantity + quantity > Integer.MAX_VALUE) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량이 최대값을 초과합니다."); + } + this.stockQuantity += quantity; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionValidator.java new file mode 100644 index 000000000..7397ddd47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionValidator.java @@ -0,0 +1,19 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public class ProductOptionValidator { + + public static void validateOptionValue(String optionValue) { + if (optionValue == null || optionValue.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "옵션값은 필수입니다."); + } + } + + public static void validateStockQuantity(Integer stockQuantity) { + if (stockQuantity == null || stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + } +} \ No newline at end of file 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..77cbcbd1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,32 @@ +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 { + + Optional findById(Long id); + + List findAllActive(); + + List findAllActiveByCategoryId(Long categoryId); + + List findAllActiveByBrandId(Long brandId); + + List findAllActiveByCategoryIds(List categoryIds); + + Page findProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable); + + Product save(Product product); + + void delete(Long id); + + void softDeleteAllByIds(List ids); + + boolean existsById(Long id); + + Page findAllIncludingDeleted(Pageable pageable); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..1063b7cee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,148 @@ +package com.loopers.domain.product; + +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.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Product getActiveProduct(Long productId) { + Product product = getProduct(productId); + if (product.isDeleted()) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 상품입니다."); + } + return product; + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public List getAllActiveProducts() { + return productRepository.findAllActive(); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public List getActiveProductsByCategoryId(Long categoryId) { + return productRepository.findAllActiveByCategoryId(categoryId); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Page getProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable) { + return productRepository.findProducts(categoryId, keyword, sort, pageable); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Product createProduct(String name, Long brandId, Long categoryId, Long basePrice, + List options, List images) { + Product product = new Product(name, brandId, categoryId, basePrice, options, images); + return productRepository.save(product); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Product createProduct(String name, Long brandId, Long categoryId, Long basePrice) { + return createProduct(name, brandId, categoryId, basePrice, null, null); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Product updateProduct(Long productId, String name, Long categoryId, Long basePrice, + Long discount, DiscountType discountType, ProductStatus status) { + Product product = getProduct(productId); + product.update(name, categoryId, basePrice, discount, discountType, status); + return productRepository.save(product); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void deleteProduct(Long productId) { + Product product = getProduct(productId); + productRepository.delete(product.getId()); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Product validateProduct(Long productId) { + Product product = getActiveProduct(productId); + if (!product.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, "판매 중인 상품만 주문 가능합니다."); + } + return product; + } + + @Transactional(propagation = Propagation.REQUIRED) + public void decreaseStock(Long productId, Long optionId, int quantity) { + Product product = getProduct(productId); + product.decreaseStock(optionId, quantity); + productRepository.save(product); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void increaseStock(Long productId, Long optionId, int quantity) { + Product product = getProduct(productId); + product.increaseStock(optionId, quantity); + productRepository.save(product); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public ProductOption getProductOption(Long productId, Long optionId) { + Product product = getProduct(productId); + return product.getOption(optionId); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void deleteProductsByBrandId(Long brandId) { + List products = productRepository.findAllActiveByBrandId(brandId); + if (!products.isEmpty()) { + List productIds = products.stream() + .map(Product::getId) + .toList(); + productRepository.softDeleteAllByIds(productIds); + } + } + + @Transactional(propagation = Propagation.REQUIRED) + public void deleteProductsByCategoryIds(List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) { + return; + } + List products = productRepository.findAllActiveByCategoryIds(categoryIds); + if (!products.isEmpty()) { + List productIds = products.stream() + .map(Product::getId) + .toList(); + productRepository.softDeleteAllByIds(productIds); + } + } + + @Transactional(propagation = Propagation.REQUIRED) + public Long increaseLikeCount(Long productId) { + Product product = getProduct(productId); + product.increaseLikeCount(); + return productRepository.save(product).getLikeCount(); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Long decreaseLikeCount(Long productId) { + Product product = getProduct(productId); + product.decreaseLikeCount(); + return productRepository.save(product).getLikeCount(); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Page getProductsForAdmin(Pageable pageable) { + return productRepository.findAllIncludingDeleted(pageable); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..ab3c71cf5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, // 최신순 (기본) + PRICE_ASC, // 가격 낮은순 + LIKES_DESC // 좋아요 많은순 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java new file mode 100644 index 000000000..350c9907e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum ProductStatus { + SALE, // 판매중 + STOP, // 판매중지 + SOLDOUT // 품절 +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java new file mode 100644 index 000000000..af178f33d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductValidator.java @@ -0,0 +1,69 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public final class ProductValidator { + + private ProductValidator() { + // 인스턴스화 방지 + } + + public static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + } + + public static void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + public static void validateCategoryId(Long categoryId) { + if (categoryId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "카테고리 ID는 필수입니다."); + } + } + + public static void validateBasePrice(Long basePrice) { + if (basePrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "기본 가격은 필수입니다."); + } + if (basePrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "기본 가격은 0 이상이어야 합니다."); + } + } + + public static void validateDiscount(Long discount, DiscountType discountType, Long basePrice) { + // 둘 다 null이면 할인 없음 - 유효 + if (discount == null && discountType == null) { + return; + } + + // 둘 중 하나만 null이면 오류 + if (discount == null || discountType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액과 할인 타입은 함께 설정되어야 합니다."); + } + + // 음수 할인 검증 + if (discount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + + if (discountType == DiscountType.RATE && discount > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100%를 초과할 수 없습니다."); + } + + if (discountType == DiscountType.PRICE && discount > basePrice) { + throw new CoreException(ErrorType.BAD_REQUEST, "정액 할인은 기본 가격을 초과할 수 없습니다."); + } + } + + public static void validateStatus(ProductStatus status) { + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 상태는 필수입니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java new file mode 100644 index 000000000..1314c19e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,203 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "products") +@SQLDelete(sql = "UPDATE products SET deleted_at = NOW() WHERE id = ?") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductEntity extends BaseEntity { + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "product_code", nullable = false, unique = true, length = 20) + private String productCode; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(name = "base_price", nullable = false) + private Long basePrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ProductStatus status; + + @Column(name = "discount") + private Long discount; + + @Enumerated(EnumType.STRING) + @Column(name = "discount_type", length = 20) + private DiscountType discountType; + + @Column(name = "like_count", nullable = false) + private Long likeCount = 0L; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private Set options = new HashSet<>(); + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private Set images = new HashSet<>(); + + public static ProductEntity from(Product product) { + ProductEntity entity = new ProductEntity(); + entity.name = product.getName(); + entity.productCode = product.getProductCode(); + entity.brandId = product.getBrandId(); + entity.categoryId = product.getCategoryId(); + entity.basePrice = product.getBasePrice(); + entity.status = product.getStatus(); + entity.discount = product.getDiscount(); + entity.discountType = product.getDiscountType(); + entity.likeCount = product.getLikeCount() != null ? product.getLikeCount() : 0L; + + if (product.getOptions() != null) { + for (ProductOption option : product.getOptions()) { + entity.addOption(ProductOptionEntity.from(option)); + } + } + if (product.getImages() != null) { + for (ProductImage image : product.getImages()) { + entity.addImage(ProductImageEntity.from(image)); + } + } + + return entity; + } + + public void addOption(ProductOptionEntity option) { + options.add(option); + option.setProduct(this); + } + + public void removeOption(ProductOptionEntity option) { + options.remove(option); + option.setProduct(null); + } + + public void addImage(ProductImageEntity image) { + images.add(image); + image.setProduct(this); + } + + public void removeImage(ProductImageEntity image) { + images.remove(image); + image.setProduct(null); + } + + public Product toDomain() { + List domainOptions = options.stream() + .filter(opt -> opt.getDeletedAt() == null) + .map(ProductOptionEntity::toDomain) + .toList(); + + List domainImages = images.stream() + .map(ProductImageEntity::toDomain) + .toList(); + + return new Product( + getId(), + name, + productCode, + brandId, + categoryId, + basePrice, + status, + discount, + discountType, + likeCount, + domainOptions, + domainImages, + getCreatedAt() != null ? getCreatedAt().toLocalDateTime() : null, + getUpdatedAt() != null ? getUpdatedAt().toLocalDateTime() : null, + getDeletedAt() != null ? getDeletedAt().toLocalDateTime() : null + ); + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void update(String name, Long categoryId, Long basePrice, + Long discount, DiscountType discountType, ProductStatus status, Long likeCount) { + this.name = name; + this.categoryId = categoryId; + this.likeCount = likeCount != null ? likeCount : 0L; + this.basePrice = basePrice; + this.discount = discount; + this.discountType = discountType; + this.status = status; + } + + public void syncOptions(List newOptions) { + if (newOptions == null || newOptions.isEmpty()) { + this.options.clear(); + return; + } + + // Update existing options and track which IDs we've seen + Set updatedIds = new HashSet<>(); + for (ProductOption domainOption : newOptions) { + if (domainOption.getId() != null) { + ProductOptionEntity existingEntity = findOptionById(domainOption.getId()); + if (existingEntity != null) { + existingEntity.updateStockQuantity(domainOption.getStockQuantity()); + updatedIds.add(domainOption.getId()); + } + } else { + // New option without ID + addOption(ProductOptionEntity.from(domainOption)); + } + } + + // Remove options that are no longer in the domain list + this.options.removeIf(opt -> opt.getId() != null && !updatedIds.contains(opt.getId())); + } + + public void syncImages(List newImages) { + this.images.clear(); + if (newImages != null) { + for (ProductImage image : newImages) { + addImage(ProductImageEntity.from(image)); + } + } + } + + public ProductOptionEntity findOptionById(Long optionId) { + return options.stream() + .filter(opt -> opt.getId().equals(optionId)) + .findFirst() + .orElse(null); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductFullTextIndexInitializer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductFullTextIndexInitializer.java new file mode 100644 index 000000000..ca2c35fe4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductFullTextIndexInitializer.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.product; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@Profile("test") +@RequiredArgsConstructor +public class ProductFullTextIndexInitializer { + + private final EntityManager entityManager; + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void initFullTextIndex() { + try { + entityManager.createNativeQuery( + "ALTER TABLE products ADD FULLTEXT INDEX idx_products_name_fulltext (name) WITH PARSER ngram" + ).executeUpdate(); + log.info("FULLTEXT index created successfully on products.name"); + } catch (Exception e) { + log.debug("FULLTEXT index already exists or creation failed: {}", e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java new file mode 100644 index 000000000..e597dd704 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductImage; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "product_images") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductImageEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + @Setter + private ProductEntity product; + + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 20) + private ImageType type; + + @Column(name = "url", nullable = false, length = 512) + private String url; + + @Column(name = "alt_text", length = 255) + private String altText; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getProductId() { + return product != null ? product.getId() : null; + } + + public static ProductImageEntity from(ProductImage productImage) { + ProductImageEntity entity = new ProductImageEntity(); + entity.type = productImage.getType(); + entity.url = productImage.getUrl(); + entity.altText = productImage.getAltText(); + return entity; + } + + public ProductImage toDomain() { + return new ProductImage( + id, + getProductId(), + type, + url, + altText, + createdAt, + updatedAt + ); + } +} \ No newline at end of file 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..8ff819b1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository, ProductJpaRepositoryCustom { + + Optional findByIdAndDeletedAtIsNull(Long id); + + List findAllByDeletedAtIsNull(); + + List findAllByCategoryIdAndDeletedAtIsNull(Long categoryId); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + List findAllByCategoryIdInAndDeletedAtIsNull(List categoryIds); + + boolean existsByIdAndDeletedAtIsNull(Long id); + + @Query("SELECT DISTINCT p FROM ProductEntity p " + + "LEFT JOIN FETCH p.options " + + "LEFT JOIN FETCH p.images " + + "WHERE p.id = :id") + Optional findByIdWithOptionsAndImages(@Param("id") Long id); + + @Modifying + @Query("UPDATE ProductEntity p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.id IN :ids AND p.deletedAt IS NULL") + void softDeleteAllByIds(@Param("ids") List ids); + + @Query("SELECT p FROM ProductEntity p") + Page findAllIncludingDeleted(Pageable pageable); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustom.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustom.java new file mode 100644 index 000000000..961f44012 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductSortType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductJpaRepositoryCustom { + + Page findProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustomImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustomImpl.java new file mode 100644 index 000000000..2765aa47b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepositoryCustomImpl.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductSortType; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +public class ProductJpaRepositoryCustomImpl implements ProductJpaRepositoryCustom { + + private final EntityManager entityManager; + + @Override + @SuppressWarnings("unchecked") + public Page findProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable) { + StringBuilder sql = new StringBuilder(); + StringBuilder countSql = new StringBuilder(); + Map params = new HashMap<>(); + + sql.append("SELECT * FROM products WHERE deleted_at IS NULL"); + countSql.append("SELECT COUNT(*) FROM products WHERE deleted_at IS NULL"); + + if (categoryId != null) { + sql.append(" AND category_id = :categoryId"); + countSql.append(" AND category_id = :categoryId"); + params.put("categoryId", categoryId); + } + + if (keyword != null && !keyword.isBlank()) { + sql.append(" AND MATCH(name) AGAINST(:keyword IN BOOLEAN MODE)"); + countSql.append(" AND MATCH(name) AGAINST(:keyword IN BOOLEAN MODE)"); + params.put("keyword", "*" + keyword + "*"); + } + + sql.append(getSortClause(sort)); + sql.append(" LIMIT :limit OFFSET :offset"); + + Query query = entityManager.createNativeQuery(sql.toString(), ProductEntity.class); + Query countQuery = entityManager.createNativeQuery(countSql.toString()); + + params.forEach((key, value) -> { + query.setParameter(key, value); + countQuery.setParameter(key, value); + }); + query.setParameter("limit", pageable.getPageSize()); + query.setParameter("offset", pageable.getOffset()); + + List content = query.getResultList(); + Long total = ((Number) countQuery.getSingleResult()).longValue(); + + return new PageImpl<>(content, pageable, total); + } + + private String getSortClause(ProductSortType sort) { + if (sort == null) { + return " ORDER BY created_at DESC"; + } + return switch (sort) { + case PRICE_ASC -> " ORDER BY base_price ASC"; + case LIKES_DESC -> " ORDER BY like_count DESC, created_at DESC"; + default -> " ORDER BY created_at DESC"; + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java new file mode 100644 index 000000000..a9583e1c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.ProductOption; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; + +@Entity +@Table(name = "product_options") +@SQLDelete(sql = "UPDATE product_options SET deleted_at = NOW() WHERE id = ?") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductOptionEntity extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + @Setter + private ProductEntity product; + + @Column(name = "option_value", nullable = false, length = 50) + private String optionValue; + + @Column(name = "display_name", length = 255) + private String displayName; + + @Column(name = "extra_price") + private Long extraPrice; + + @Column(name = "stock_quantity") + private Integer stockQuantity; + + public static ProductOptionEntity from(ProductOption productOption) { + ProductOptionEntity entity = new ProductOptionEntity(); + entity.optionValue = productOption.getOptionValue(); + entity.displayName = productOption.getDisplayName(); + entity.extraPrice = productOption.getExtraPrice(); + entity.stockQuantity = productOption.getStockQuantity(); + return entity; + } + + public Long getProductId() { + return product != null ? product.getId() : null; + } + + public ProductOption toDomain() { + return new ProductOption( + getId(), + getProductId(), + optionValue, + displayName, + extraPrice, + stockQuantity, + getCreatedAt() != null ? getCreatedAt().toLocalDateTime() : null, + getUpdatedAt() != null ? getUpdatedAt().toLocalDateTime() : null, + getDeletedAt() != null ? getDeletedAt().toLocalDateTime() : null + ); + } + + public void updateStockQuantity(Integer stockQuantity) { + this.stockQuantity = stockQuantity; + } +} \ No newline at end of file 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..bc029d26e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,108 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +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.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdWithOptionsAndImages(id) + .map(ProductEntity::toDomain); + } + + @Override + public List findAllActive() { + return productJpaRepository.findAllByDeletedAtIsNull().stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveByCategoryId(Long categoryId) { + return productJpaRepository.findAllByCategoryIdAndDeletedAtIsNull(categoryId).stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId).stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveByCategoryIds(List categoryIds) { + return productJpaRepository.findAllByCategoryIdInAndDeletedAtIsNull(categoryIds).stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public Page findProducts(Long categoryId, String keyword, ProductSortType sort, Pageable pageable) { + return productJpaRepository.findProducts(categoryId, keyword, sort, pageable) + .map(ProductEntity::toDomain); + } + + @Override + public Product save(Product product) { + ProductEntity entity; + if (product.getId() != null) { + entity = productJpaRepository.findByIdWithOptionsAndImages(product.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + entity.update( + product.getName(), + product.getCategoryId(), + product.getBasePrice(), + product.getDiscount(), + product.getDiscountType(), + product.getStatus(), + product.getLikeCount() + ); + entity.syncOptions(product.getOptions()); + entity.syncImages(product.getImages()); + } else { + entity = ProductEntity.from(product); + } + ProductEntity saved = productJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public void delete(Long id) { + productJpaRepository.deleteById(id); + } + + @Override + public void softDeleteAllByIds(List ids) { + if (ids != null && !ids.isEmpty()) { + productJpaRepository.softDeleteAllByIds(ids); + } + } + + @Override + public boolean existsById(Long id) { + return productJpaRepository.existsByIdAndDeletedAtIsNull(id); + } + + @Override + public Page findAllIncludingDeleted(Pageable pageable) { + return productJpaRepository.findAllIncludingDeleted(pageable) + .map(ProductEntity::toDomain); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..b217bae75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,50 @@ +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.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product Admin V1 API", description = "상품 관리자 API 입니다.") +public interface ProductAdminV1ApiSpec { + + @Operation( + summary = "상품 목록 조회 (Admin)", + description = "관리자용 상품 목록을 조회합니다. 삭제된 상품도 포함됩니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "관리자 권한 필요") + }) + ApiResponse> getProducts(String ldap, Pageable pageable); + + @Operation( + summary = "상품 상세 조회 (Admin)", + description = "관리자용 상품 상세 정보를 조회합니다." + ) + ApiResponse getProduct(String ldap, Long productId); + + @Operation( + summary = "상품 등록", + description = "새로운 상품을 등록합니다." + ) + ApiResponse createProduct( + String ldap, ProductAdminV1Dto.CreateProductRequest request + ); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse updateProduct( + String ldap, Long productId, ProductAdminV1Dto.UpdateProductRequest request + ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다. (Soft Delete)" + ) + ApiResponse deleteProduct(String ldap, Long productId); +} \ No newline at end of file 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..bd4fe0f32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductAdminDetailInfo; +import com.loopers.application.product.ProductCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getProducts( + @RequestHeader("X-Loopers-Ldap") String ldap, + Pageable pageable + ) { + Page infos = productFacade.getProductsForAdmin(ldap, pageable); + Page response = infos.map(ProductAdminV1Dto.ProductDetailResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId + ) { + ProductAdminDetailInfo info = productFacade.getProductDetail(ldap, productId); + ProductAdminV1Dto.ProductDetailResponse response = ProductAdminV1Dto.ProductDetailResponse.from(info); + return ApiResponse.success(response); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @Valid @RequestBody ProductAdminV1Dto.CreateProductRequest request + ) { + ProductCommand.Create command = new ProductCommand.Create( + request.name(), request.brandId(), request.categoryId(), request.basePrice() + ); + ProductAdminDetailInfo info = productFacade.createProduct(ldap, command); + ProductAdminV1Dto.ProductDetailResponse response = ProductAdminV1Dto.ProductDetailResponse.from(info); + return ApiResponse.success(response); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId, + @Valid @RequestBody ProductAdminV1Dto.UpdateProductRequest request + ) { + ProductCommand.Update command = new ProductCommand.Update( + request.name(), request.categoryId(), request.basePrice(), + request.discount(), request.discountType(), request.status() + ); + ProductAdminDetailInfo info = productFacade.updateProduct(ldap, productId, command); + ProductAdminV1Dto.ProductDetailResponse response = ProductAdminV1Dto.ProductDetailResponse.from(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId + ) { + productFacade.deleteProduct(ldap, productId); + return ApiResponse.success(); + } +} \ No newline at end of file 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..305c491fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,125 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductAdminDetailInfo; +import com.loopers.application.product.ProductImageInfo; +import com.loopers.application.product.ProductOptionInfo; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public class ProductAdminV1Dto { + + public record CreateProductRequest( + @NotBlank(message = "상품명은 비어있을 수 없습니다.") + @Size(max = 100, message = "상품명은 100자를 초과할 수 없습니다.") + String name, + + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotNull(message = "카테고리 ID는 필수입니다.") + Long categoryId, + + @NotNull(message = "기본 가격은 필수입니다.") + @PositiveOrZero(message = "기본 가격은 0 이상이어야 합니다.") + Long basePrice + ) {} + + public record UpdateProductRequest( + @NotBlank(message = "상품명은 비어있을 수 없습니다.") + @Size(max = 100, message = "상품명은 100자를 초과할 수 없습니다.") + String name, + + @NotNull(message = "카테고리 ID는 필수입니다.") + Long categoryId, + + @NotNull(message = "기본 가격은 필수입니다.") + @PositiveOrZero(message = "기본 가격은 0 이상이어야 합니다.") + Long basePrice, + + Long discount, + DiscountType discountType, + + @NotNull(message = "상품 상태는 필수입니다.") + ProductStatus status + ) {} + + public record OptionResponse( + Long id, + String optionValue, + String displayName, + Long extraPrice, + Integer stockQuantity + ) { + public static OptionResponse from(ProductOptionInfo info) { + return new OptionResponse( + info.id(), + info.optionValue(), + info.displayName(), + info.extraPrice(), + info.stockQuantity() + ); + } + } + + public record ImageResponse( + Long id, + ImageType type, + String url, + String altText + ) { + public static ImageResponse from(ProductImageInfo info) { + return new ImageResponse( + info.id(), + info.type(), + info.url(), + info.altText() + ); + } + } + + public record ProductDetailResponse( + Long id, + String name, + String productCode, + Long brandId, + Long categoryId, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + List options, + List images, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt + ) { + public static ProductDetailResponse from(ProductAdminDetailInfo info) { + return new ProductDetailResponse( + info.id(), + info.name(), + info.productCode(), + info.brandId(), + info.categoryId(), + info.basePrice(), + info.discountedPrice(), + info.status(), + info.discount(), + info.discountType(), + info.options().stream().map(OptionResponse::from).toList(), + info.images().stream().map(ImageResponse::from).toList(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..197827459 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "활성 상품 목록을 조회합니다. 브랜드 정보와 좋아요 수를 포함합니다. 검색, 정렬, 페이징을 지원합니다." + ) + ApiResponse> getProducts( + @Parameter(description = "카테고리 ID (null이면 전체)") Long categoryId, + @Parameter(description = "검색 키워드 (상품명 검색)") String keyword, + @Parameter(description = "정렬 기준 (LATEST: 최신순, PRICE_ASC: 가격 낮은순, LIKES_DESC: 좋아요순)") ProductSortType sort, + @Parameter(description = "페이징 정보") Pageable pageable + ); + + @Operation( + summary = "상품 상세 조회", + description = "상품 상세 정보를 조회합니다. 브랜드 정보, 좋아요 수, 옵션 및 이미지를 포함합니다." + ) + ApiResponse getProduct(Long productId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..ca162bc04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getProducts( + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "LATEST") ProductSortType sort, + @PageableDefault(size = 20) Pageable pageable + ) { + Page infos = productFacade.getProducts(categoryId, keyword, sort, pageable); + Page response = infos.map(ProductV1Dto.ProductResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + ProductDetailInfo info = productFacade.getProduct(productId); + ProductV1Dto.ProductDetailResponse response = ProductV1Dto.ProductDetailResponse.from(info); + return ApiResponse.success(response); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..7d74ce92f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,122 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductImageInfo; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductOptionInfo; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.ProductStatus; + +import java.util.List; + +public class ProductV1Dto { + + public record BrandResponse( + Long id, + String name, + String logoImageUrl + ) { + public static BrandResponse from(com.loopers.application.brand.BrandInfo info) { + return new BrandResponse( + info.id(), + info.name(), + info.logoImageUrl() + ); + } + } + + public record OptionResponse( + Long id, + String optionValue, + String displayName, + Long extraPrice, + Integer stockQuantity + ) { + public static OptionResponse from(ProductOptionInfo info) { + return new OptionResponse( + info.id(), + info.optionValue(), + info.displayName(), + info.extraPrice(), + info.stockQuantity() + ); + } + } + + public record ImageResponse( + Long id, + ImageType type, + String url, + String altText + ) { + public static ImageResponse from(ProductImageInfo info) { + return new ImageResponse( + info.id(), + info.type(), + info.url(), + info.altText() + ); + } + } + + public record ProductResponse( + Long id, + String name, + String productCode, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + BrandResponse brand, + Long likeCount + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.productCode(), + info.basePrice(), + info.discountedPrice(), + info.status(), + info.discount(), + info.discountType(), + info.brand() != null ? BrandResponse.from(info.brand()) : null, + info.likeCount() + ); + } + } + + public record ProductDetailResponse( + Long id, + String name, + String productCode, + Long basePrice, + Long discountedPrice, + ProductStatus status, + Long discount, + DiscountType discountType, + BrandResponse brand, + Long likeCount, + List options, + List images + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + return new ProductDetailResponse( + info.id(), + info.name(), + info.productCode(), + info.basePrice(), + info.discountedPrice(), + info.status(), + info.discount(), + info.discountType(), + info.brand() != null ? BrandResponse.from(info.brand()) : null, + info.likeCount(), + info.options().stream().map(OptionResponse::from).toList(), + info.images().stream().map(ImageResponse::from).toList() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..ad3969ed4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,551 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +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.BeforeEach; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("ProductFacade 통합 테스트") +class ProductFacadeTest { + + @Autowired + private ProductFacade productFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Brand savedBrand; + private Category savedCategory; + + @BeforeEach + void setUp() { + savedBrand = brandRepository.save(new Brand("Apple", "애플", "https://example.com/apple.png")); + savedCategory = categoryRepository.save(new Category("전자제품")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getProduct") + class GetProduct { + + @Test + @DisplayName("상품 정보를 브랜드 정보와 함께 조회한다") + void returnsProductInfoWithBrand() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L) + ); + + // Act + ProductDetailInfo result = productFacade.getProduct(product.getId()); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(product.getId()), + () -> assertThat(result.name()).isEqualTo("아이폰 15"), + () -> assertThat(result.brand()).isNotNull(), + () -> assertThat(result.brand().name()).isEqualTo("Apple"), + () -> assertThat(result.likeCount()).isEqualTo(0L), + () -> assertThat(result.options()).isEmpty(), + () -> assertThat(result.images()).isEmpty() + ); + } + + @Test + @DisplayName("상품 정보에 옵션과 이미지가 포함된다") + void returnsProductInfoWithOptionsAndImages() { + // Arrange + List options = List.of( + new ProductOption(null, "256GB", "256GB", 0L, 100), + new ProductOption(null, "512GB", "512GB", 100000L, 50) + ); + List images = List.of( + new ProductImage(null, ImageType.MAIN, "https://example.com/main.jpg", "메인 이미지") + ); + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L, options, images) + ); + + // Act + ProductDetailInfo result = productFacade.getProduct(product.getId()); + + // Assert + assertAll( + () -> assertThat(result.options()).hasSize(2), + () -> assertThat(result.options()).extracting(ProductOptionInfo::optionValue) + .containsExactlyInAnyOrder("256GB", "512GB"), + () -> assertThat(result.images()).hasSize(1), + () -> assertThat(result.images()).extracting(ProductImageInfo::type) + .containsExactly(ImageType.MAIN) + ); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productFacade.getProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getProducts") + class GetProducts { + + @Test + @DisplayName("페이지로 상품 목록을 조회하고 브랜드 정보를 포함한다") + void returnsPagedProductsWithBrandInfo() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("아이폰 14", savedBrand.getId(), 1L, 1200000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productFacade.getProducts(null, null, ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).allMatch(info -> info.brand() != null), + () -> assertThat(result.getContent()).allMatch(info -> info.brand().name().equals("Apple")) + ); + } + + @Test + @DisplayName("페이징 정보가 정상적으로 반환된다") + void returnsCorrectPagingInfo() { + // Arrange + for (int i = 0; i < 25; i++) { + productRepository.save(new Product("상품" + i, savedBrand.getId(), 1L, 1000000L + i)); + } + Pageable pageable = PageRequest.of(0, 20); + + // Act + Page result = productFacade.getProducts(null, null, ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(20), + () -> assertThat(result.getTotalElements()).isEqualTo(25), + () -> assertThat(result.getTotalPages()).isEqualTo(2), + () -> assertThat(result.isFirst()).isTrue(), + () -> assertThat(result.hasNext()).isTrue() + ); + } + + @Test + @DisplayName("좋아요 많은순으로 정렬하여 조회한다") + void returnsProducts_sortedByLikesDesc() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", savedBrand.getId(), 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // 좋아요 수 설정: product2(5) > product1(3) > product3(1) + product1.increaseLikeCount(); + product1.increaseLikeCount(); + product1.increaseLikeCount(); + productRepository.save(product1); + + product2.increaseLikeCount(); + product2.increaseLikeCount(); + product2.increaseLikeCount(); + product2.increaseLikeCount(); + product2.increaseLikeCount(); + productRepository.save(product2); + + product3.increaseLikeCount(); + productRepository.save(product3); + + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productFacade.getProducts(null, null, ProductSortType.LIKES_DESC, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(3), + () -> assertThat(result.getContent().get(0).id()).isEqualTo(product2.getId()), + () -> assertThat(result.getContent().get(0).likeCount()).isEqualTo(5L), + () -> assertThat(result.getContent().get(1).id()).isEqualTo(product1.getId()), + () -> assertThat(result.getContent().get(1).likeCount()).isEqualTo(3L), + () -> assertThat(result.getContent().get(2).id()).isEqualTo(product3.getId()), + () -> assertThat(result.getContent().get(2).likeCount()).isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 수가 동일하면 최신순으로 정렬한다") + void returnsProducts_sortedByCreatedAtDesc_whenLikeCountSame() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", savedBrand.getId(), 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // 모든 상품 좋아요 수 동일하게 설정 (각 1개) + product1.increaseLikeCount(); + productRepository.save(product1); + + product2.increaseLikeCount(); + productRepository.save(product2); + + product3.increaseLikeCount(); + productRepository.save(product3); + + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productFacade.getProducts(null, null, ProductSortType.LIKES_DESC, pageable); + + // Assert - 좋아요 수 동일하면 최신순 (product3 > product2 > product1) + assertAll( + () -> assertThat(result.getContent()).hasSize(3), + () -> assertThat(result.getContent().get(0).id()).isEqualTo(product3.getId()), + () -> assertThat(result.getContent().get(1).id()).isEqualTo(product2.getId()), + () -> assertThat(result.getContent().get(2).id()).isEqualTo(product1.getId()) + ); + } + } + + @Nested + @DisplayName("createProduct (Admin)") + class CreateProduct { + + @Test + @DisplayName("관리자가 상품을 정상적으로 생성한다") + void createsProduct() { + // Arrange + ProductCommand.Create command = new ProductCommand.Create( + "아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L + ); + + // Act + ProductAdminDetailInfo result = productFacade.createProduct("loopers.admin", command); + + // Assert + assertAll( + () -> assertThat(result.id()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("아이폰 15"), + () -> assertThat(result.brandId()).isEqualTo(savedBrand.getId()), + () -> assertThat(result.status()).isEqualTo(ProductStatus.SALE), + () -> assertThat(result.options()).isEmpty(), + () -> assertThat(result.images()).isEmpty() + ); + } + + @Test + @DisplayName("관리자가 아니면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNotAdmin() { + // Arrange + ProductCommand.Create command = new ProductCommand.Create( + "아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.createProduct("invalid.ldap", command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 상품 생성 시 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandNotExists() { + // Arrange + ProductCommand.Create command = new ProductCommand.Create( + "아이폰 15", 99999L, savedCategory.getId(), 1500000L + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.createProduct("loopers.admin", command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 카테고리로 상품 생성 시 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenCategoryNotExists() { + // Arrange + ProductCommand.Create command = new ProductCommand.Create( + "아이폰 15", savedBrand.getId(), 99999L, 1500000L + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.createProduct("loopers.admin", command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("deleteProduct (Admin)") + class DeleteProduct { + + @Test + @DisplayName("관리자가 상품을 삭제한다") + void deletesProduct() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L) + ); + + // Act + productFacade.deleteProduct("loopers.admin", product.getId()); + + // Assert + assertThatThrownBy(() -> productFacade.getProduct(product.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("updateProduct (Admin)") + class UpdateProduct { + + @Test + @DisplayName("관리자가 상품을 정상적으로 수정한다") + void updatesProduct() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + ProductCommand.Update command = new ProductCommand.Update( + "아이폰 15 Pro", savedCategory.getId(), 1800000L, + 100000L, DiscountType.PRICE, ProductStatus.SALE + ); + + // Act + ProductAdminDetailInfo result = productFacade.updateProduct("loopers.admin", product.getId(), command); + + // Assert + assertAll( + () -> assertThat(result.name()).isEqualTo("아이폰 15 Pro"), + () -> assertThat(result.basePrice()).isEqualTo(1800000L), + () -> assertThat(result.discount()).isEqualTo(100000L), + () -> assertThat(result.discountType()).isEqualTo(DiscountType.PRICE), + () -> assertThat(result.discountedPrice()).isEqualTo(1700000L) + ); + } + + @Test + @DisplayName("잘못된 할인 정보로 수정 시 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenInvalidDiscount() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + ProductCommand.Update command = new ProductCommand.Update( + "아이폰 15 Pro", savedCategory.getId(), 1000000L, + 1500000L, DiscountType.PRICE, ProductStatus.SALE // 할인이 가격보다 큼 + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.updateProduct("loopers.admin", product.getId(), command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("할인 금액만 있고 할인 타입이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenDiscountWithoutType() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + ProductCommand.Update command = new ProductCommand.Update( + "아이폰 15 Pro", savedCategory.getId(), 1500000L, + 100000L, null, ProductStatus.SALE + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.updateProduct("loopers.admin", product.getId(), command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("관리자가 아니면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNotAdmin() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + ProductCommand.Update command = new ProductCommand.Update( + "아이폰 15 Pro", savedCategory.getId(), 1800000L, + null, null, ProductStatus.SALE + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.updateProduct("invalid.ldap", product.getId(), command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("getProductDetail (Admin)") + class GetProductDetail { + + @Test + @DisplayName("관리자가 상품 상세 정보를 조회한다") + void returnsProductDetail() { + // Arrange + List options = List.of( + new ProductOption(null, "256GB", "256GB", 0L, 100) + ); + List images = List.of( + new ProductImage(null, ImageType.MAIN, "https://example.com/main.jpg", "메인 이미지") + ); + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L, options, images) + ); + + // Act + ProductAdminDetailInfo result = productFacade.getProductDetail("loopers.admin", product.getId()); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(product.getId()), + () -> assertThat(result.name()).isEqualTo("아이폰 15"), + () -> assertThat(result.options()).hasSize(1), + () -> assertThat(result.images()).hasSize(1) + ); + } + + @Test + @DisplayName("관리자가 삭제된 상품도 조회할 수 있다") + void returnsDeletedProduct() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + productFacade.deleteProduct("loopers.admin", product.getId()); + + // Act + ProductAdminDetailInfo result = productFacade.getProductDetail("loopers.admin", product.getId()); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(product.getId()), + () -> assertThat(result.deletedAt()).isNotNull() + ); + } + + @Test + @DisplayName("관리자가 아니면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNotAdmin() { + // Arrange + Product product = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + + // Act & Assert + assertThatThrownBy(() -> productFacade.getProductDetail("invalid.ldap", product.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } + + @Nested + @DisplayName("getProductsForAdmin") + class GetProductsForAdmin { + + @Test + @DisplayName("관리자가 페이지로 상품 목록을 조회한다") + void returnsPagedProducts() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + productRepository.save(new Product("갤럭시 S24", savedBrand.getId(), savedCategory.getId(), 1400000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productFacade.getProductsForAdmin("loopers.admin", pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getTotalElements()).isEqualTo(2) + ); + } + + @Test + @DisplayName("관리자가 삭제된 상품도 포함하여 조회할 수 있다") + void includesDeletedProducts() { + // Arrange + Product activeProduct = productRepository.save( + new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L) + ); + Product deletedProduct = productRepository.save( + new Product("갤럭시 S24", savedBrand.getId(), savedCategory.getId(), 1400000L) + ); + productFacade.deleteProduct("loopers.admin", deletedProduct.getId()); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productFacade.getProductsForAdmin("loopers.admin", pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()) + .anyMatch(p -> p.deletedAt() != null) + ); + } + + @Test + @DisplayName("관리자가 아니면 FORBIDDEN 예외가 발생한다") + void throwsForbidden_whenNotAdmin() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + + // Act & Assert + assertThatThrownBy(() -> productFacade.getProductsForAdmin("invalid.ldap", pageable)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageTest.java new file mode 100644 index 000000000..cc8e5bac7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductImageTest.java @@ -0,0 +1,82 @@ +package com.loopers.domain.product; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("ProductImage 도메인 단위 테스트") +class ProductImageTest { + + @Nested + @DisplayName("ProductImage 생성") + class Create { + + @Test + @DisplayName("모든 필수값이 유효하면 정상적으로 생성된다") + void createsProductImage_whenAllFieldsAreValid() { + // Arrange & Act + ProductImage image = new ProductImage(1L, ImageType.MAIN, "https://example.com/image.jpg", "상품 이미지"); + + // Assert + assertAll( + () -> assertThat(image.getProductId()).isEqualTo(1L), + () -> assertThat(image.getType()).isEqualTo(ImageType.MAIN), + () -> assertThat(image.getUrl()).isEqualTo("https://example.com/image.jpg"), + () -> assertThat(image.getAltText()).isEqualTo("상품 이미지") + ); + } + + @Test + @DisplayName("productId가 null이면 정상 생성된다 (애그리거트 루트를 통해 설정)") + void createsProductImage_whenProductIdIsNull() { + // Arrange & Act + ProductImage image = new ProductImage(null, ImageType.MAIN, "https://example.com/image.jpg", "상품 이미지"); + + // Assert + assertThat(image.getProductId()).isNull(); + } + + @Test + @DisplayName("url이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUrlIsNull() { + // Act & Assert + assertThatThrownBy(() -> new ProductImage(1L, ImageType.MAIN, null, "상품 이미지")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("url이 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUrlIsEmpty() { + // Act & Assert + assertThatThrownBy(() -> new ProductImage(1L, ImageType.MAIN, "", "상품 이미지")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("altText가 null이면 정상 생성된다") + void createsProductImage_whenAltTextIsNull() { + // Arrange & Act + ProductImage image = new ProductImage(1L, ImageType.MAIN, "https://example.com/image.jpg", null); + + // Assert + assertThat(image.getAltText()).isNull(); + } + + @Test + @DisplayName("type이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenTypeIsNull() { + // Act & Assert + assertThatThrownBy(() -> new ProductImage(1L, null, "https://example.com/image.jpg", "상품 이미지")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductOptionTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductOptionTest.java new file mode 100644 index 000000000..e7487e810 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductOptionTest.java @@ -0,0 +1,227 @@ +package com.loopers.domain.product; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("ProductOption 도메인 단위 테스트") +class ProductOptionTest { + + @Nested + @DisplayName("ProductOption 생성") + class Create { + + @Test + @DisplayName("모든 필수값이 유효하면 정상적으로 생성된다") + void createsProductOption_whenAllFieldsAreValid() { + // Arrange & Act + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Assert + assertAll( + () -> assertThat(option.getProductId()).isEqualTo(1L), + () -> assertThat(option.getOptionValue()).isEqualTo("BLACK_M"), + () -> assertThat(option.getDisplayName()).isEqualTo("블랙 / M"), + () -> assertThat(option.getExtraPrice()).isEqualTo(5000L), + () -> assertThat(option.getStockQuantity()).isEqualTo(100) + ); + } + + @Test + @DisplayName("productId가 null이면 정상 생성된다 (애그리거트 루트를 통해 설정)") + void createsProductOption_whenProductIdIsNull() { + // Arrange & Act + ProductOption option = new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 100); + + // Assert + assertThat(option.getProductId()).isNull(); + } + + @Test + @DisplayName("optionValue가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenOptionValueIsNull() { + // Act & Assert + assertThatThrownBy(() -> new ProductOption(1L, null, "블랙 / M", 5000L, 100)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("optionValue가 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenOptionValueIsEmpty() { + // Act & Assert + assertThatThrownBy(() -> new ProductOption(1L, "", "블랙 / M", 5000L, 100)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("stockQuantity가 음수이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStockQuantityIsNegative() { + // Act & Assert + assertThatThrownBy(() -> new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, -1)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("extraPrice가 null이면 0으로 설정된다") + void setsZero_whenExtraPriceIsNull() { + // Arrange & Act + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", null, 100); + + // Assert + assertThat(option.getExtraPrice()).isEqualTo(0L); + } + + @Test + @DisplayName("stockQuantity가 0이면 정상 생성된다") + void createsProductOption_whenStockQuantityIsZero() { + // Arrange & Act + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 0); + + // Assert + assertThat(option.getStockQuantity()).isEqualTo(0); + } + } + + @Nested + @DisplayName("재고 차감") + class DecreaseStock { + + @Test + @DisplayName("재고가 충분하면 정상적으로 차감된다") + void decreasesStock_whenStockIsSufficient() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act + option.decreaseStock(30); + + // Assert + assertThat(option.getStockQuantity()).isEqualTo(70); + } + + @Test + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStockIsInsufficient() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 10); + + // Act & Assert + assertThatThrownBy(() -> option.decreaseStock(20)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("차감 수량이 0이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenQuantityIsZero() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act & Assert + assertThatThrownBy(() -> option.decreaseStock(0)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("차감 수량이 음수이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenQuantityIsNegative() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act & Assert + assertThatThrownBy(() -> option.decreaseStock(-5)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("재고 전량을 차감하면 재고가 0이 된다") + void decreasesToZero_whenDecreasingAllStock() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 50); + + // Act + option.decreaseStock(50); + + // Assert + assertThat(option.getStockQuantity()).isEqualTo(0); + } + } + + @Nested + @DisplayName("재고 증가") + class IncreaseStock { + + @Test + @DisplayName("재고가 정상적으로 증가한다") + void increasesStock() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act + option.increaseStock(50); + + // Assert + assertThat(option.getStockQuantity()).isEqualTo(150); + } + + @Test + @DisplayName("증가 수량이 0이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenQuantityIsZero() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act & Assert + assertThatThrownBy(() -> option.increaseStock(0)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("증가 수량이 음수이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenQuantityIsNegative() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 100); + + // Act & Assert + assertThatThrownBy(() -> option.increaseStock(-10)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("재고 증가 시 상한선(Integer.MAX_VALUE)을 초과하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStockExceedsMaxValue() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, Integer.MAX_VALUE - 10); + + // Act & Assert + assertThatThrownBy(() -> option.increaseStock(20)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("재고 증가 시 정확히 상한선(Integer.MAX_VALUE)이면 정상적으로 증가한다") + void increasesStock_whenResultEqualsMaxValue() { + // Arrange + ProductOption option = new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, Integer.MAX_VALUE - 10); + + // Act + option.increaseStock(10); + + // Assert + assertThat(option.getStockQuantity()).isEqualTo(Integer.MAX_VALUE); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..693af6ea6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,747 @@ +package com.loopers.domain.product; + +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("ProductService 통합 테스트") +class ProductServiceTest { + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getProduct") + class GetProduct { + + @Test + @DisplayName("존재하는 상품을 조회하면 Product를 반환한다") + void returnsProduct_whenProductExists() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Product result = productService.getProduct(saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getName()).isEqualTo("아이폰 15") + ); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productService.getProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getActiveProduct") + class GetActiveProduct { + + @Test + @DisplayName("활성 상품을 조회하면 Product를 반환한다") + void returnsProduct_whenProductIsActive() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Product result = productService.getActiveProduct(saved.getId()); + + // Assert + assertThat(result.getName()).isEqualTo("아이폰 15"); + } + + @Test + @DisplayName("삭제된 상품을 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductIsDeleted() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productService.deleteProduct(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> productService.getActiveProduct(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productService.getActiveProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("createProduct") + class CreateProduct { + + @Test + @DisplayName("상품을 정상적으로 생성한다") + void createsProduct() { + // Act + Product result = productService.createProduct("아이폰 15", 1L, 1L, 1500000L); + + // Assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("아이폰 15"), + () -> assertThat(result.getBrandId()).isEqualTo(1L), + () -> assertThat(result.getCategoryId()).isEqualTo(1L), + () -> assertThat(result.getBasePrice()).isEqualTo(1500000L), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.SALE), + () -> assertThat(result.getProductCode()).matches("\\d{8}-\\d{5}") + ); + } + } + + @Nested + @DisplayName("updateProduct") + class UpdateProduct { + + @Test + @DisplayName("상품을 정상적으로 수정한다") + void updatesProduct() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Product result = productService.updateProduct( + saved.getId(), "아이폰 15 Pro", 2L, 1800000L, + 100000L, DiscountType.PRICE, ProductStatus.STOP + ); + + // Assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("아이폰 15 Pro"), + () -> assertThat(result.getCategoryId()).isEqualTo(2L), + () -> assertThat(result.getBasePrice()).isEqualTo(1800000L), + () -> assertThat(result.getDiscount()).isEqualTo(100000L), + () -> assertThat(result.getDiscountType()).isEqualTo(DiscountType.PRICE), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.STOP) + ); + } + + @Test + @DisplayName("존재하지 않는 상품을 수정하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productService.updateProduct( + 999L, "아이폰 15 Pro", 2L, 1800000L, + null, null, ProductStatus.SALE + )) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("deleteProduct") + class DeleteProduct { + + @Test + @DisplayName("상품을 삭제하면 Soft Delete 된다") + void deletesProduct() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + productService.deleteProduct(saved.getId()); + + // Assert + Product deleted = productService.getProduct(saved.getId()); + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 상품을 삭제하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productService.deleteProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("getProducts") + class GetProducts { + + @Test + @DisplayName("전체 상품 목록을 페이지로 조회한다") + void returnsPagedProducts_whenNoFilter() { + // Arrange + productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(3), + () -> assertThat(result.getTotalElements()).isEqualTo(3), + () -> assertThat(result.getTotalPages()).isEqualTo(1) + ); + } + + @Test + @DisplayName("카테고리 ID로 필터링하여 상품 목록을 조회한다") + void returnsFilteredProducts_whenCategoryIdProvided() { + // Arrange + productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(1L, null, ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()).allMatch(p -> p.getCategoryId().equals(1L)) + ); + } + + @Test + @DisplayName("키워드로 검색하여 상품 목록을 조회한다") + void returnsFilteredProducts_whenKeywordProvided() { + // Arrange + productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + productRepository.save(new Product("아이폰 15 Pro", 1L, 1L, 1800000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, "아이폰", ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()).allMatch(p -> p.getName().contains("아이폰")) + ); + } + + @Test + @DisplayName("최신순으로 정렬하여 조회한다") + void returnsProducts_sortedByLatest() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.LATEST, pageable); + + // Assert + assertThat(result.getContent().get(0).getId()).isEqualTo(product3.getId()); + } + + @Test + @DisplayName("가격 낮은순으로 정렬하여 조회한다") + void returnsProducts_sortedByPriceAsc() { + // Arrange + productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.PRICE_ASC, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent().get(0).getBasePrice()).isEqualTo(1400000L), + () -> assertThat(result.getContent().get(1).getBasePrice()).isEqualTo(1500000L), + () -> assertThat(result.getContent().get(2).getBasePrice()).isEqualTo(3000000L) + ); + } + + @Test + @DisplayName("페이징이 정상적으로 동작한다") + void returnsPagedProducts_withPagination() { + // Arrange + for (int i = 0; i < 25; i++) { + productRepository.save(new Product("상품" + i, 1L, 1L, 1000000L + i)); + } + Pageable firstPage = PageRequest.of(0, 10); + Pageable secondPage = PageRequest.of(1, 10); + Pageable thirdPage = PageRequest.of(2, 10); + + // Act + Page firstResult = productService.getProducts(null, null, ProductSortType.LATEST, firstPage); + Page secondResult = productService.getProducts(null, null, ProductSortType.LATEST, secondPage); + Page thirdResult = productService.getProducts(null, null, ProductSortType.LATEST, thirdPage); + + // Assert + assertAll( + () -> assertThat(firstResult.getContent()).hasSize(10), + () -> assertThat(secondResult.getContent()).hasSize(10), + () -> assertThat(thirdResult.getContent()).hasSize(5), + () -> assertThat(firstResult.getTotalElements()).isEqualTo(25), + () -> assertThat(firstResult.getTotalPages()).isEqualTo(3) + ); + } + + @Test + @DisplayName("삭제된 상품은 조회되지 않는다") + void excludesDeletedProducts() { + // Arrange + Product activeProduct = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + Product toDelete = productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + productService.deleteProduct(toDelete.getId()); + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.LATEST, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).getId()).isEqualTo(activeProduct.getId()) + ); + } + + @Test + @DisplayName("좋아요 많은순으로 정렬하여 조회한다") + void returnsProducts_sortedByLikesDesc() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + + // 좋아요 수 설정: product2(5) > product1(3) > product3(1) + for (int i = 0; i < 3; i++) productService.increaseLikeCount(product1.getId()); + for (int i = 0; i < 5; i++) productService.increaseLikeCount(product2.getId()); + productService.increaseLikeCount(product3.getId()); + + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.LIKES_DESC, pageable); + + // Assert + assertAll( + () -> assertThat(result.getContent().get(0).getId()).isEqualTo(product2.getId()), + () -> assertThat(result.getContent().get(0).getLikeCount()).isEqualTo(5L), + () -> assertThat(result.getContent().get(1).getId()).isEqualTo(product1.getId()), + () -> assertThat(result.getContent().get(1).getLikeCount()).isEqualTo(3L), + () -> assertThat(result.getContent().get(2).getId()).isEqualTo(product3.getId()), + () -> assertThat(result.getContent().get(2).getLikeCount()).isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 수가 같으면 최신순으로 정렬한다") + void returnsProducts_sortedByCreatedAtDesc_whenLikeCountIsSame() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", 2L, 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", 1L, 2L, 3000000L)); + + // 모든 상품 좋아요 수 동일하게 설정 + productService.increaseLikeCount(product1.getId()); + productService.increaseLikeCount(product2.getId()); + productService.increaseLikeCount(product3.getId()); + + Pageable pageable = PageRequest.of(0, 10); + + // Act + Page result = productService.getProducts(null, null, ProductSortType.LIKES_DESC, pageable); + + // Assert - 좋아요 수 동일하면 최신순 (product3 > product2 > product1) + assertAll( + () -> assertThat(result.getContent().get(0).getId()).isEqualTo(product3.getId()), + () -> assertThat(result.getContent().get(1).getId()).isEqualTo(product2.getId()), + () -> assertThat(result.getContent().get(2).getId()).isEqualTo(product1.getId()) + ); + } + } + + @Nested + @DisplayName("validateProduct") + class ValidateProduct { + + @Test + @DisplayName("SALE 상태 상품은 검증을 통과한다") + void passesValidation_whenProductIsSale() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Product result = productService.validateProduct(saved.getId()); + + // Assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(saved.getId()), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.SALE) + ); + } + + @Test + @DisplayName("STOP 상태 상품은 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenProductIsStop() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productService.updateProduct( + saved.getId(), "아이폰 15", 1L, 1500000L, + null, null, ProductStatus.STOP + ); + + // Act & Assert + assertThatThrownBy(() -> productService.validateProduct(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("SOLDOUT 상태 상품은 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenProductIsSoldout() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productService.updateProduct( + saved.getId(), "아이폰 15", 1L, 1500000L, + null, null, ProductStatus.SOLDOUT + ); + + // Act & Assert + assertThatThrownBy(() -> productService.validateProduct(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("삭제된 상품은 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductIsDeleted() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productService.deleteProduct(saved.getId()); + + // Act & Assert + assertThatThrownBy(() -> productService.validateProduct(saved.getId())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 상품은 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductNotExists() { + // Act & Assert + assertThatThrownBy(() -> productService.validateProduct(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("increaseStock") + class IncreaseStock { + + @Test + @DisplayName("재고를 정상적으로 증가시킨다") + void increasesStock() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 100) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + + // Act + productService.increaseStock(saved.getId(), optionId, 50); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertThat(result.getOption(optionId).getStockQuantity()).isEqualTo(150); + } + + @Test + @DisplayName("재고 증가 시 상한선을 초과하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStockExceedsMaxValue() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, Integer.MAX_VALUE - 10) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + + // Act & Assert + assertThatThrownBy(() -> productService.increaseStock(saved.getId(), optionId, 20)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("존재하지 않는 옵션의 재고를 증가시키면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenOptionNotExists() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act & Assert + assertThatThrownBy(() -> productService.increaseStock(saved.getId(), 999L, 10)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("SOLDOUT 상태에서 재고가 증가하면 SALE로 복구된다") + void changesStatusToSale_whenStockRestored() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 0) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + // 상품을 SOLDOUT 상태로 변경 + productService.updateProduct(saved.getId(), "아이폰 15", 1L, 1500000L, null, null, ProductStatus.SOLDOUT); + + // Act + productService.increaseStock(saved.getId(), optionId, 10); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertAll( + () -> assertThat(result.getOption(optionId).getStockQuantity()).isEqualTo(10), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.SALE) + ); + } + + @Test + @DisplayName("STOP 상태에서 재고가 증가해도 STOP 상태를 유지한다") + void maintainsStopStatus_whenStockRestored() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 0) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + // 상품을 STOP 상태로 변경 + productService.updateProduct(saved.getId(), "아이폰 15", 1L, 1500000L, null, null, ProductStatus.STOP); + + // Act + productService.increaseStock(saved.getId(), optionId, 10); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertAll( + () -> assertThat(result.getOption(optionId).getStockQuantity()).isEqualTo(10), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.STOP) + ); + } + } + + @Nested + @DisplayName("decreaseStock") + class DecreaseStock { + + @Test + @DisplayName("재고를 정상적으로 감소시킨다") + void decreasesStock() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 100) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + + // Act + productService.decreaseStock(saved.getId(), optionId, 30); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertThat(result.getOption(optionId).getStockQuantity()).isEqualTo(70); + } + + @Test + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStockIsInsufficient() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 10) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + Long optionId = saved.getOptions().get(0).getId(); + + // Act & Assert + assertThatThrownBy(() -> productService.decreaseStock(saved.getId(), optionId, 20)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("존재하지 않는 옵션의 재고를 감소시키면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenOptionNotExists() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act & Assert + assertThatThrownBy(() -> productService.decreaseStock(saved.getId(), 999L, 10)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("모든 옵션의 재고가 0이 되면 SOLDOUT으로 상태가 변경된다") + void changesStatusToSoldout_whenAllStockDepleted() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 10), + new ProductOption(null, "WHITE_M", "화이트 / M", 5000L, 5) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + + // 다시 조회하여 저장된 옵션 ID 확인 (optionValue로 식별) + Product loaded = productService.getProduct(saved.getId()); + ProductOption blackOption = loaded.getOptions().stream() + .filter(o -> o.getOptionValue().equals("BLACK_M")) + .findFirst().orElseThrow(); + ProductOption whiteOption = loaded.getOptions().stream() + .filter(o -> o.getOptionValue().equals("WHITE_M")) + .findFirst().orElseThrow(); + + // Act - 모든 재고 소진 + productService.decreaseStock(saved.getId(), blackOption.getId(), 10); + productService.decreaseStock(saved.getId(), whiteOption.getId(), 5); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertAll( + () -> assertThat(result.getOption(blackOption.getId()).getStockQuantity()).isEqualTo(0), + () -> assertThat(result.getOption(whiteOption.getId()).getStockQuantity()).isEqualTo(0), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.SOLDOUT) + ); + } + + @Test + @DisplayName("일부 옵션의 재고만 0이면 SALE 상태를 유지한다") + void maintainsSaleStatus_whenSomeOptionsHaveStock() { + // Arrange + List options = List.of( + new ProductOption(null, "BLACK_M", "블랙 / M", 5000L, 10), + new ProductOption(null, "WHITE_M", "화이트 / M", 5000L, 5) + ); + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L, options, null)); + + // 다시 조회하여 저장된 옵션 ID 확인 (optionValue로 식별) + Product loaded = productService.getProduct(saved.getId()); + ProductOption blackOption = loaded.getOptions().stream() + .filter(o -> o.getOptionValue().equals("BLACK_M")) + .findFirst().orElseThrow(); + + // Act - 첫 번째 옵션만 재고 소진 + productService.decreaseStock(saved.getId(), blackOption.getId(), 10); + + // Assert + Product result = productService.getProduct(saved.getId()); + assertAll( + () -> assertThat(result.getOption(blackOption.getId()).getStockQuantity()).isEqualTo(0), + () -> assertThat(result.getStatus()).isEqualTo(ProductStatus.SALE) + ); + } + } + + @Nested + @DisplayName("increaseLikeCount") + class IncreaseLikeCount { + + @Test + @DisplayName("좋아요 수를 정상적으로 증가시킨다") + void increasesLikeCount() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Long result = productService.increaseLikeCount(saved.getId()); + + // Assert + assertThat(result).isEqualTo(1L); + } + + @Test + @DisplayName("좋아요 수를 여러 번 증가시키면 누적된다") + void accumulates_whenIncreasedMultipleTimes() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + productService.increaseLikeCount(saved.getId()); + productService.increaseLikeCount(saved.getId()); + Long result = productService.increaseLikeCount(saved.getId()); + + // Assert + assertThat(result).isEqualTo(3L); + } + } + + @Nested + @DisplayName("decreaseLikeCount") + class DecreaseLikeCount { + + @Test + @DisplayName("좋아요 수를 정상적으로 감소시킨다") + void decreasesLikeCount() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + productService.increaseLikeCount(saved.getId()); + productService.increaseLikeCount(saved.getId()); + + // Act + Long result = productService.decreaseLikeCount(saved.getId()); + + // Assert + assertThat(result).isEqualTo(1L); + } + + @Test + @DisplayName("좋아요 수가 0이면 0 미만으로 감소하지 않는다") + void doesNotGoBelowZero() { + // Arrange + Product saved = productRepository.save(new Product("아이폰 15", 1L, 1L, 1500000L)); + + // Act + Long result = productService.decreaseLikeCount(saved.getId()); + + // Assert + assertThat(result).isEqualTo(0L); + } + } +} \ No newline at end of file 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..abb815c45 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,552 @@ +package com.loopers.domain.product; + +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 java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Product 도메인 단위 테스트") +class ProductTest { + + @Nested + @DisplayName("Product 생성") + class Create { + + @Test + @DisplayName("모든 필수값이 유효하면 정상적으로 생성된다") + void createsProduct_whenAllRequiredFieldsAreValid() { + // Arrange & Act + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("아이폰 15"), + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getCategoryId()).isEqualTo(1L), + () -> assertThat(product.getBasePrice()).isEqualTo(1500000L), + () -> assertThat(product.getStatus()).isEqualTo(ProductStatus.SALE), + () -> assertThat(product.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("productCode가 자동 생성된다 (YYYYMMDD-5자리)") + void generatesProductCode_whenCreated() { + // Arrange & Act + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Assert + assertThat(product.getProductCode()).isNotNull(); + assertThat(product.getProductCode()).matches("\\d{8}-\\d{5}"); + } + + @Test + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsNull() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product(null, 1L, 1L, 1500000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name이 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsEmpty() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product("", 1L, 1L, 1500000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("brandId가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBrandIdIsNull() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product("아이폰 15", null, 1L, 1500000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("categoryId가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenCategoryIdIsNull() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product("아이폰 15", 1L, null, 1500000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("basePrice가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBasePriceIsNull() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product("아이폰 15", 1L, 1L, null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("basePrice가 0 미만이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBasePriceIsNegative() { + // Arrange & Act & Assert + assertThatThrownBy(() -> new Product("아이폰 15", 1L, 1L, -1L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("basePrice가 0이면 정상적으로 생성된다 (무료 상품)") + void createsProduct_whenBasePriceIsZero() { + // Arrange & Act + Product product = new Product("무료 상품", 1L, 1L, 0L); + + // Assert + assertThat(product.getBasePrice()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("calculateDiscountedPrice - 할인가 계산") + class CalculateDiscountedPrice { + + @Test + @DisplayName("할인이 없으면 기본가를 반환한다") + void returnsBasePrice_whenNoDiscount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + Long discountedPrice = product.calculateDiscountedPrice(); + + // Assert + assertThat(discountedPrice).isEqualTo(1500000L); + } + + @Test + @DisplayName("PRICE 타입: 정액 할인을 적용한다") + void appliesPriceDiscount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.applyDiscount(100000L, DiscountType.PRICE); + + // Act + Long discountedPrice = product.calculateDiscountedPrice(); + + // Assert + assertThat(discountedPrice).isEqualTo(1400000L); + } + + @Test + @DisplayName("RATE 타입: 정률 할인을 적용한다 (10% 할인)") + void appliesRateDiscount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1000000L); + product.applyDiscount(10L, DiscountType.RATE); + + // Act + Long discountedPrice = product.calculateDiscountedPrice(); + + // Assert + assertThat(discountedPrice).isEqualTo(900000L); + } + + @Test + @DisplayName("PRICE 타입: 할인가가 기본가보다 크면 예외가 발생한다") + void throwsException_whenPriceDiscountExceedsBasePrice() { + // Arrange + Product product = new Product("저가 상품", 1L, 1L, 50000L); + + // Act & Assert + assertThatThrownBy(() -> product.applyDiscount(100000L, DiscountType.PRICE)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("applyDiscount - 할인 적용") + class ApplyDiscount { + + @Test + @DisplayName("RATE 타입에서 discount가 100 초과이면 예외가 발생한다") + void throwsException_whenRateDiscountExceeds100() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act & Assert + assertThatThrownBy(() -> product.applyDiscount(101L, DiscountType.RATE)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("PRICE 타입에서 discount가 basePrice 초과이면 예외가 발생한다") + void throwsException_whenPriceDiscountExceedsBasePrice() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 100000L); + + // Act & Assert + assertThatThrownBy(() -> product.applyDiscount(150000L, DiscountType.PRICE)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("PRICE 타입에서 discount가 basePrice와 같으면 정상 적용된다 (100% 할인)") + void appliesDiscount_whenPriceDiscountEqualsBasePrice() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 100000L); + + // Act + product.applyDiscount(100000L, DiscountType.PRICE); + + // Assert + assertAll( + () -> assertThat(product.getDiscount()).isEqualTo(100000L), + () -> assertThat(product.getDiscountType()).isEqualTo(DiscountType.PRICE), + () -> assertThat(product.calculateDiscountedPrice()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("할인을 제거할 수 있다") + void removesDiscount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.applyDiscount(100000L, DiscountType.PRICE); + + // Act + product.removeDiscount(); + + // Assert + assertAll( + () -> assertThat(product.getDiscount()).isNull(), + () -> assertThat(product.getDiscountType()).isNull(), + () -> assertThat(product.calculateDiscountedPrice()).isEqualTo(1500000L) + ); + } + } + + @Nested + @DisplayName("Product update") + class Update { + + @Test + @DisplayName("모든 필드를 정상적으로 업데이트한다") + void updatesAllFields() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + product.update("아이폰 15 Pro", 2L, 1800000L, 50000L, DiscountType.PRICE, ProductStatus.STOP); + + // Assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("아이폰 15 Pro"), + () -> assertThat(product.getCategoryId()).isEqualTo(2L), + () -> assertThat(product.getBasePrice()).isEqualTo(1800000L), + () -> assertThat(product.getDiscount()).isEqualTo(50000L), + () -> assertThat(product.getDiscountType()).isEqualTo(DiscountType.PRICE), + () -> assertThat(product.getStatus()).isEqualTo(ProductStatus.STOP) + ); + } + + @Test + @DisplayName("brandId는 변경되지 않는다") + void brandIdRemainsUnchanged() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + Long originalBrandId = product.getBrandId(); + + // Act + product.update("아이폰 15 Pro", 2L, 1800000L, null, null, ProductStatus.SALE); + + // Assert + assertThat(product.getBrandId()).isEqualTo(originalBrandId); + } + + @Test + @DisplayName("name을 null로 업데이트하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUpdateNameToNull() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act & Assert + assertThatThrownBy(() -> product.update(null, 2L, 1800000L, null, null, ProductStatus.SALE)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("name을 빈 문자열로 업데이트하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenUpdateNameToEmpty() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act & Assert + assertThatThrownBy(() -> product.update("", 2L, 1800000L, null, null, ProductStatus.SALE)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("Product status - isAvailable") + class IsAvailable { + + @Test + @DisplayName("SALE 상태이고 삭제되지 않으면 true를 반환한다") + void returnsTrue_whenSaleAndNotDeleted() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act & Assert + assertThat(product.isAvailable()).isTrue(); + } + + @Test + @DisplayName("STOP 상태이면 false를 반환한다") + void returnsFalse_whenStop() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.update("아이폰 15", 1L, 1500000L, null, null, ProductStatus.STOP); + + // Act & Assert + assertThat(product.isAvailable()).isFalse(); + } + + @Test + @DisplayName("삭제된 상태이면 false를 반환한다") + void returnsFalse_whenDeleted() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.delete(); + + // Act & Assert + assertThat(product.isAvailable()).isFalse(); + } + } + + @Nested + @DisplayName("Product delete") + class Delete { + + @Test + @DisplayName("delete 호출 시 deletedAt이 설정된다") + void setsDeletedAt_whenDeleteCalled() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + product.delete(); + + // Assert + assertThat(product.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상태에서 delete 호출해도 예외가 발생하지 않는다 (멱등성)") + void doesNotThrow_whenDeleteCalledTwice() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.delete(); + + // Act & Assert (멱등성) + product.delete(); + assertThat(product.isDeleted()).isTrue(); + } + } + + @Nested + @DisplayName("likeCount - 좋아요 수 관리") + class LikeCount { + + @Test + @DisplayName("생성 시 likeCount 기본값은 0이다") + void likeCountIsZero_whenCreated() { + // Arrange & Act + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("increaseLikeCount 호출 시 likeCount가 1 증가한다") + void increasesLikeCount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + product.increaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("decreaseLikeCount 호출 시 likeCount가 1 감소한다") + void decreasesLikeCount() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.increaseLikeCount(); + product.increaseLikeCount(); + + // Act + product.decreaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("likeCount가 0일 때 decreaseLikeCount 호출해도 0 미만이 되지 않는다") + void doesNotGoBelowZero_whenDecreaseCalledAtZero() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + product.decreaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("DB 조회 데이터 복원 (toDomain)") + class RestoreFromDatabase { + + @Test + @DisplayName("DB에서 조회한 데이터로 Product 도메인 객체를 복원한다") + void restoresProductFromDatabaseRecord() { + // Arrange + LocalDateTime createdAt = LocalDateTime.of(2024, 1, 1, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2024, 1, 2, 10, 0); + + // Act + Product product = new Product( + 1L, "아이폰 15", "20240101-00001", 1L, 1L, 1500000L, + ProductStatus.SALE, 100000L, DiscountType.PRICE, 0L, + createdAt, updatedAt, null + ); + + // Assert + assertAll( + () -> assertThat(product.getId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("아이폰 15"), + () -> assertThat(product.getProductCode()).isEqualTo("20240101-00001"), + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getCategoryId()).isEqualTo(1L), + () -> assertThat(product.getBasePrice()).isEqualTo(1500000L), + () -> assertThat(product.getStatus()).isEqualTo(ProductStatus.SALE), + () -> assertThat(product.getDiscount()).isEqualTo(100000L), + () -> assertThat(product.getDiscountType()).isEqualTo(DiscountType.PRICE), + () -> assertThat(product.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("삭제된 상품 데이터를 복원하면 isDeleted가 true를 반환한다") + void returnsTrue_whenRestoredProductWasDeleted() { + // Arrange + LocalDateTime now = LocalDateTime.now(); + + // Act + Product product = new Product( + 1L, "아이폰 15", "20240101-00001", 1L, 1L, 1500000L, + ProductStatus.SALE, null, null, 0L, + now, now, now + ); + + // Assert + assertThat(product.isDeleted()).isTrue(); + } + } + + @Nested + @DisplayName("SoldoutStatusTransition - 재고 기반 상태 전환") + class SoldoutStatusTransition { + + @Test + @DisplayName("모든 옵션의 재고 합계를 반환한다") + void returnsTotalStockQuantity() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.addOption(new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 10)); + product.addOption(new ProductOption(2L, "WHITE_M", "화이트 / M", 5000L, 20)); + product.addOption(new ProductOption(3L, "BLACK_L", "블랙 / L", 5000L, 30)); + + // Act + int totalStock = product.getTotalStockQuantity(); + + // Assert + assertThat(totalStock).isEqualTo(60); + } + + @Test + @DisplayName("옵션이 없으면 총 재고는 0을 반환한다") + void returnZero_whenNoOptions() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + + // Act + int totalStock = product.getTotalStockQuantity(); + + // Assert + assertThat(totalStock).isEqualTo(0); + } + + @Test + @DisplayName("모든 재고가 0이 되면 SALE에서 SOLDOUT으로 상태가 변경된다") + void changesStatusToSoldout_whenTotalStockIsZero() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.addOption(new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 0)); + product.addOption(new ProductOption(2L, "WHITE_M", "화이트 / M", 5000L, 0)); + + // Act + product.checkAndUpdateSoldoutStatus(); + + // Assert + assertThat(product.getStatus()).isEqualTo(ProductStatus.SOLDOUT); + } + + @Test + @DisplayName("SOLDOUT 상태에서 재고가 추가되면 SALE로 복구된다") + void changesStatusToSale_whenStockRestoredFromSoldout() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.addOption(new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 10)); + product.update("아이폰 15", 1L, 1500000L, null, null, ProductStatus.SOLDOUT); + + // Act + product.checkAndUpdateSoldoutStatus(); + + // Assert + assertThat(product.getStatus()).isEqualTo(ProductStatus.SALE); + } + + @Test + @DisplayName("STOP 상태는 재고와 상관없이 유지된다") + void maintainsStopStatus_regardlessOfStock() { + // Arrange + Product product = new Product("아이폰 15", 1L, 1L, 1500000L); + product.addOption(new ProductOption(1L, "BLACK_M", "블랙 / M", 5000L, 0)); + product.update("아이폰 15", 1L, 1500000L, null, null, ProductStatus.STOP); + + // Act + product.checkAndUpdateSoldoutStatus(); + + // Assert + assertThat(product.getStatus()).isEqualTo(ProductStatus.STOP); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductValidatorTest.java new file mode 100644 index 000000000..8b1eb7108 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductValidatorTest.java @@ -0,0 +1,238 @@ +package com.loopers.domain.product; + +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.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductValidator 단위 테스트") +class ProductValidatorTest { + + @Nested + @DisplayName("validateName") + class ValidateName { + + @Test + @DisplayName("유효한 상품명이면 예외가 발생하지 않는다") + void doesNotThrow_whenNameIsValid() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateName("아이폰 15")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("상품명이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsNull() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateName(null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("상품명이 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsEmpty() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateName("")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("상품명이 공백만 있으면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsBlank() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateName(" ")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + } + + @Nested + @DisplayName("validateBasePrice") + class ValidateBasePrice { + + @Test + @DisplayName("유효한 가격이면 예외가 발생하지 않는다") + void doesNotThrow_whenBasePriceIsValid() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateBasePrice(1000L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("가격이 0이면 예외가 발생하지 않는다 (무료 상품 허용)") + void doesNotThrow_whenBasePriceIsZero() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateBasePrice(0L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("가격이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBasePriceIsNull() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateBasePrice(null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("가격이 음수이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBasePriceIsNegative() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateBasePrice(-1L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + } + + @Nested + @DisplayName("validateDiscount") + class ValidateDiscount { + + @Test + @DisplayName("할인 정보가 둘 다 null이면 예외가 발생하지 않는다") + void doesNotThrow_whenBothAreNull() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateDiscount(null, null, 10000L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("정액 할인이 유효하면 예외가 발생하지 않는다") + void doesNotThrow_whenPriceDiscountIsValid() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateDiscount(1000L, DiscountType.PRICE, 10000L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("정률 할인이 유효하면 예외가 발생하지 않는다") + void doesNotThrow_whenRateDiscountIsValid() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateDiscount(50L, DiscountType.RATE, 10000L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("할인 금액이 음수이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenDiscountIsNegative() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateDiscount(-100L, DiscountType.PRICE, 10000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("할인 금액만 있고 타입이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenOnlyDiscountIsProvided() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateDiscount(1000L, null, 10000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("할인 타입만 있고 금액이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenOnlyDiscountTypeIsProvided() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateDiscount(null, DiscountType.PRICE, 10000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("정률 할인이 100%를 초과하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenRateDiscountExceeds100() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateDiscount(101L, DiscountType.RATE, 10000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("정률 할인이 정확히 100%이면 예외가 발생하지 않는다") + void doesNotThrow_whenRateDiscountIs100() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateDiscount(100L, DiscountType.RATE, 10000L)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("정액 할인이 기본 가격을 초과하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPriceDiscountExceedsBasePrice() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateDiscount(15000L, DiscountType.PRICE, 10000L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + + @Test + @DisplayName("정액 할인이 기본 가격과 같으면 예외가 발생하지 않는다") + void doesNotThrow_whenPriceDiscountEqualsBasePrice() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateDiscount(10000L, DiscountType.PRICE, 10000L)) + .doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("validateStatus") + class ValidateStatus { + + @Test + @DisplayName("유효한 상태이면 예외가 발생하지 않는다") + void doesNotThrow_whenStatusIsValid() { + // Act & Assert + assertThatCode(() -> ProductValidator.validateStatus(ProductStatus.SALE)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("상태가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenStatusIsNull() { + // Act & Assert + assertThatThrownBy(() -> ProductValidator.validateStatus(null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> { + CoreException coreEx = (CoreException) ex; + assert coreEx.getErrorType() == ErrorType.BAD_REQUEST; + }); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ApiE2ETest.java new file mode 100644 index 000000000..e50ee50eb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ApiE2ETest.java @@ -0,0 +1,694 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.DiscountType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductStatus; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Product Admin V1 API E2E 테스트") +class ProductAdminV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/admin/products"; + private static final String VALID_ADMIN_LDAP = "loopers.admin"; + private static final String INVALID_ADMIN_LDAP = "invalid.ldap"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Brand savedBrand; + private Category savedCategory; + + @BeforeEach + void setUp() { + savedBrand = brandRepository.save(new Brand("Apple", "애플", "https://example.com/apple.png")); + savedCategory = categoryRepository.save(new Category("전자제품")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", VALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private HttpHeaders createInvalidAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", INVALID_ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + @Nested + @DisplayName("GET /api/v1/admin/products") + class GetProducts { + + @Test + @DisplayName("Admin이 상품 목록을 조회하면 200 OK를 반환한다") + void returnsOk_whenAdminRequests() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 조회하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminRequests() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("삭제된 상품도 목록에 포함된다") + void includesDeletedProducts() { + // Arrange + Product activeProduct = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product toDelete = productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + productService.deleteProduct(toDelete.getId()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2) + ); + } + + @Test + @DisplayName("삭제된 상품은 deletedAt 필드가 포함되어 반환된다") + void returnsDeletedAtField_whenProductIsDeleted() { + // Arrange + Product toDelete = productRepository.save(new Product("삭제될 상품", savedBrand.getId(), 1L, 1000000L)); + productService.deleteProduct(toDelete.getId()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + @SuppressWarnings("unchecked") + List> content = (List>) response.getBody().data().get("content"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(content).hasSize(1), + () -> assertThat(content.get(0).get("deletedAt")).isNotNull() + ); + } + + @Test + @DisplayName("페이징이 정상적으로 동작한다") + void returnsPaginatedProducts() { + // Arrange + for (int i = 0; i < 15; i++) { + productRepository.save(new Product("상품" + i, savedBrand.getId(), 1L, 1000000L + i)); + } + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(10), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(15), + () -> assertThat(response.getBody().data().get("totalPages")).isEqualTo(2) + ); + } + } + + @Nested + @DisplayName("GET /api/v1/admin/products/{productId}") + class GetProduct { + + @Test + @DisplayName("Admin이 상품 상세를 조회하면 200 OK를 반환한다") + void returnsOk_whenAdminRequests() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("아이폰 15"), + () -> assertThat(response.getBody().data().get("basePrice")).isEqualTo(1500000) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 조회하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminRequests() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면 404 Not Found를 반환한다") + void returnsNotFound_whenProductNotExists() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/99999", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("삭제된 상품도 조회할 수 있다") + void returnsDeletedProduct() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + productService.deleteProduct(product.getId()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("deletedAt")).isNotNull() + ); + } + } + + @Nested + @DisplayName("POST /api/v1/admin/products") + class CreateProduct { + + @Test + @DisplayName("Admin이 상품을 등록하면 201 Created를 반환한다") + void returnsCreated_whenAdminCreates() { + // Arrange + String requestBody = """ + { + "name": "새 상품", + "brandId": %d, + "categoryId": %d, + "basePrice": 50000 + } + """.formatted(savedBrand.getId(), savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + request, + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("새 상품"), + () -> assertThat(response.getBody().data().get("basePrice")).isEqualTo(50000) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 등록하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminCreates() { + // Arrange + String requestBody = """ + { + "name": "새 상품", + "brandId": %d, + "categoryId": %d, + "basePrice": 50000 + } + """.formatted(savedBrand.getId(), savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createInvalidAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("필수 필드가 누락되면 400 Bad Request를 반환한다") + void returnsBadRequest_whenRequiredFieldMissing() { + // Arrange - name 누락 + String requestBody = """ + { + "brandId": %d, + "categoryId": %d, + "basePrice": 50000 + } + """.formatted(savedBrand.getId(), savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 브랜드 ID로 등록하면 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // Arrange + String requestBody = """ + { + "name": "새 상품", + "brandId": 99999, + "categoryId": %d, + "basePrice": 50000 + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않는 카테고리 ID로 등록하면 404 Not Found를 반환한다") + void returnsNotFound_whenCategoryNotExists() { + // Arrange + String requestBody = """ + { + "name": "새 상품", + "brandId": %d, + "categoryId": 99999, + "basePrice": 50000 + } + """.formatted(savedBrand.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("PUT /api/v1/admin/products/{productId}") + class UpdateProduct { + + @Test + @DisplayName("Admin이 상품을 수정하면 200 OK를 반환한다") + void returnsOk_whenAdminUpdates() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + String requestBody = """ + { + "name": "아이폰 15 Pro", + "categoryId": %d, + "basePrice": 1800000, + "status": "SALE" + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.PUT, + request, + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("아이폰 15 Pro"), + () -> assertThat(response.getBody().data().get("basePrice")).isEqualTo(1800000) + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 수정하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminUpdates() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + String requestBody = """ + { + "name": "아이폰 15 Pro", + "categoryId": %d, + "basePrice": 1800000, + "status": "SALE" + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createInvalidAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.PUT, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 상품을 수정하면 404 Not Found를 반환한다") + void returnsNotFound_whenProductNotExists() { + // Arrange + String requestBody = """ + { + "name": "아이폰 15 Pro", + "categoryId": %d, + "basePrice": 1800000, + "status": "SALE" + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/99999", + HttpMethod.PUT, + request, + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("상태를 STOP으로 변경할 수 있다") + void updatesStatusToStop() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + String requestBody = """ + { + "name": "아이폰 15", + "categoryId": %d, + "basePrice": 1500000, + "status": "STOP" + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.PUT, + request, + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("status")).isEqualTo("STOP") + ); + } + + @Test + @DisplayName("할인 정보를 설정할 수 있다") + void updatesDiscountInfo() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + String requestBody = """ + { + "name": "아이폰 15", + "categoryId": %d, + "basePrice": 1500000, + "discount": 10, + "discountType": "RATE", + "status": "SALE" + } + """.formatted(savedCategory.getId()); + + HttpEntity request = new HttpEntity<>(requestBody, createAdminHeaders()); + + // Act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.PUT, + request, + responseType + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("discount")).isEqualTo(10), + () -> assertThat(response.getBody().data().get("discountType")).isEqualTo("RATE"), + () -> assertThat(response.getBody().data().get("discountedPrice")).isEqualTo(1350000) + ); + } + } + + @Nested + @DisplayName("DELETE /api/v1/admin/products/{productId}") + class DeleteProduct { + + @Test + @DisplayName("Admin이 상품을 삭제하면 200 OK를 반환한다") + void returnsOk_whenAdminDeletes() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("삭제 후 상품 조회 시 deletedAt이 설정된다") + void setsDeletedAt_afterDeletion() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + + // Act + testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + new ParameterizedTypeReference>() {} + ); + + // Assert - 삭제된 상품 조회 + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> getResponse = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + assertAll( + () -> assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(getResponse.getBody().data().get("deletedAt")).isNotNull() + ); + } + + @Test + @DisplayName("Admin이 아닌 사용자가 삭제하면 403 Forbidden을 반환한다") + void returnsForbidden_whenNonAdminDeletes() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), savedCategory.getId(), 1500000L)); + + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createInvalidAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 상품을 삭제하면 404 Not Found를 반환한다") + void returnsNotFound_whenProductNotExists() { + // Act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/99999", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..bf51b9f1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -0,0 +1,343 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.data.domain.Page; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Product V1 API E2E 테스트") +class ProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Brand savedBrand; + private Brand savedBrand2; + + @BeforeEach + void setUp() { + savedBrand = brandRepository.save(new Brand("Apple", "애플", "https://example.com/apple.png")); + savedBrand2 = brandRepository.save(new Brand("Samsung", "삼성", "https://example.com/samsung.png")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/products") + class GetProducts { + + @Test + @DisplayName("상품 목록을 페이지로 조회한다") + void returnsPagedProducts() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("content")).isInstanceOf(List.class), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(3), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(3) + ); + } + + @Test + @DisplayName("카테고리 ID로 필터링하여 조회한다") + void returnsFilteredProductsByCategoryId() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?categoryId=1", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(2), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2) + ); + } + + @Test + @DisplayName("키워드로 검색하여 조회한다") + void returnsFilteredProductsByKeyword() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + productRepository.save(new Product("아이폰 15 Pro", savedBrand.getId(), 1L, 1800000L)); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?keyword=아이폰", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(2), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(2) + ); + } + + @Test + @DisplayName("가격 낮은순으로 정렬하여 조회한다") + void returnsProductsSortedByPriceAsc() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?sort=PRICE_ASC", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + @SuppressWarnings("unchecked") + List> content = (List>) response.getBody().data().get("content"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(content.get(0).get("basePrice")).isEqualTo(1400000), + () -> assertThat(content.get(1).get("basePrice")).isEqualTo(1500000), + () -> assertThat(content.get(2).get("basePrice")).isEqualTo(3000000) + ); + } + + @Test + @DisplayName("페이징 파라미터가 정상적으로 동작한다") + void returnsPaginatedProducts() { + // Arrange + for (int i = 0; i < 25; i++) { + productRepository.save(new Product("상품" + i, savedBrand.getId(), 1L, 1000000L + i)); + } + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(10), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(25), + () -> assertThat(response.getBody().data().get("totalPages")).isEqualTo(3) + ); + } + + @Test + @DisplayName("기본 페이지 사이즈는 20이다") + void returnsDefaultPageSize() { + // Arrange + for (int i = 0; i < 25; i++) { + productRepository.save(new Product("상품" + i, savedBrand.getId(), 1L, 1000000L + i)); + } + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(20), + () -> assertThat(response.getBody().data().get("size")).isEqualTo(20) + ); + } + + @Test + @DisplayName("삭제된 상품은 목록에서 제외된다") + void excludesDeletedProducts() { + // Arrange + Product activeProduct = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product toDelete = productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + productService.deleteProduct(toDelete.getId()); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat((List) response.getBody().data().get("content")).hasSize(1), + () -> assertThat(response.getBody().data().get("totalElements")).isEqualTo(1) + ); + } + + @Test + @DisplayName("브랜드 정보가 포함되어 조회된다") + void returnsProductsWithBrandInfo() { + // Arrange + productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + @SuppressWarnings("unchecked") + List> content = (List>) response.getBody().data().get("content"); + @SuppressWarnings("unchecked") + Map brand = (Map) content.get(0).get("brand"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brand).isNotNull(), + () -> assertThat(brand.get("name")).isEqualTo("Apple") + ); + } + + @Test + @DisplayName("좋아요 많은순으로 정렬하여 조회한다") + void returnsProductsSortedByLikesDesc() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // 좋아요 수 설정: product2(5) > product1(3) > product3(1) + for (int i = 0; i < 3; i++) productService.increaseLikeCount(product1.getId()); + for (int i = 0; i < 5; i++) productService.increaseLikeCount(product2.getId()); + productService.increaseLikeCount(product3.getId()); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?sort=LIKES_DESC", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + @SuppressWarnings("unchecked") + List> content = (List>) response.getBody().data().get("content"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(content).hasSize(3), + () -> assertThat(((Number) content.get(0).get("likeCount")).longValue()).isEqualTo(5L), + () -> assertThat(((Number) content.get(1).get("likeCount")).longValue()).isEqualTo(3L), + () -> assertThat(((Number) content.get(2).get("likeCount")).longValue()).isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 수가 동일하면 최신순으로 정렬한다") + void returnsProductsSortedByCreatedAtDesc_whenLikeCountSame() { + // Arrange + Product product1 = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + Product product2 = productRepository.save(new Product("갤럭시 S24", savedBrand2.getId(), 1L, 1400000L)); + Product product3 = productRepository.save(new Product("맥북 프로", savedBrand.getId(), 2L, 3000000L)); + + // 모든 상품 좋아요 수 동일하게 설정 (각 1개) + productService.increaseLikeCount(product1.getId()); + productService.increaseLikeCount(product2.getId()); + productService.increaseLikeCount(product3.getId()); + + // Act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?sort=LIKES_DESC", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert - 좋아요 수 동일하면 최신순 (product3 > product2 > product1) + @SuppressWarnings("unchecked") + List> content = (List>) response.getBody().data().get("content"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(content).hasSize(3), + () -> assertThat(content.get(0).get("name")).isEqualTo("맥북 프로"), + () -> assertThat(content.get(1).get("name")).isEqualTo("갤럭시 S24"), + () -> assertThat(content.get(2).get("name")).isEqualTo("아이폰 15") + ); + } + } + + @Nested + @DisplayName("GET /api/v1/products/{productId}") + class GetProduct { + + @Test + @DisplayName("상품 상세 정보를 조회한다") + void returnsProductDetail() { + // Arrange + Product product = productRepository.save(new Product("아이폰 15", savedBrand.getId(), 1L, 1500000L)); + + // Act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + product.getId(), HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("아이폰 15"), + () -> assertThat(response.getBody().data().brand().name()).isEqualTo("Apple"), + () -> assertThat(response.getBody().data().options()).isEmpty(), + () -> assertThat(response.getBody().data().images()).isEmpty() + ); + } + + @Test + @DisplayName("존재하지 않는 상품 조회 시 404 응답을 반환한다") + void returns404_whenProductNotFound() { + // Act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {}); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/product-admin-v1.http b/http/product-admin-v1.http new file mode 100644 index 000000000..f275bed50 --- /dev/null +++ b/http/product-admin-v1.http @@ -0,0 +1,39 @@ +### 상품 목록 조회 (Admin) - 삭제된 상품 포함 +GET http://localhost:8080/api/v1/admin/products?page=0&size=20 +Accept: application/json +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 (Admin) +GET http://localhost:8080/api/v1/admin/products/1 +Accept: application/json +X-Loopers-Ldap: loopers.admin + +### 상품 등록 (Admin) +POST http://localhost:8080/api/v1/admin/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "아이폰 15", + "brandId": 1, + "categoryId": 1, + "basePrice": 1500000 +} + +### 상품 수정 (Admin) +PUT http://localhost:8080/api/v1/admin/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "아이폰 15 Pro", + "categoryId": 1, + "basePrice": 1800000, + "discount": 100000, + "discountType": "FIXED", + "status": "ON_SALE" +} + +### 상품 삭제 (Admin) +DELETE http://localhost:8080/api/v1/admin/products/1 +X-Loopers-Ldap: loopers.admin diff --git a/http/product-v1.http b/http/product-v1.http new file mode 100644 index 000000000..3dc6217ae --- /dev/null +++ b/http/product-v1.http @@ -0,0 +1,43 @@ +### 상품 목록 조회 (기본) +GET http://localhost:8080/api/v1/products +Accept: application/json + +### 상품 목록 조회 (페이징) +GET http://localhost:8080/api/v1/products?page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (페이징 - 30개) +GET http://localhost:8080/api/v1/products?page=0&size=30 +Accept: application/json + +### 상품 목록 조회 (페이징 - 50개) +GET http://localhost:8080/api/v1/products?page=0&size=50 +Accept: application/json + +### 상품 목록 조회 (카테고리 필터링) +GET http://localhost:8080/api/v1/products?categoryId=1&page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (키워드 검색) +GET http://localhost:8080/api/v1/products?keyword=아이폰&page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (최신순 정렬 - 기본) +GET http://localhost:8080/api/v1/products?sort=LATEST&page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (가격 낮은순 정렬) +GET http://localhost:8080/api/v1/products?sort=PRICE_ASC&page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (좋아요순 정렬) +GET http://localhost:8080/api/v1/products?sort=LIKES_DESC&page=0&size=20 +Accept: application/json + +### 상품 목록 조회 (카테고리 + 키워드 + 정렬 조합) +GET http://localhost:8080/api/v1/products?categoryId=1&keyword=아이폰&sort=PRICE_ASC&page=0&size=20 +Accept: application/json + +### 상품 상세 조회 +GET http://localhost:8080/api/v1/products/1 +Accept: application/json From 12741057aaa1322d32504fde7fe9160644ad7ab0 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:10:11 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Order, OrderProduct Entity 구현 - OrderStatus (PENDING, PAID, SHIPPING, DELIVERED, CANCELLED) 상태 관리 - OrderProductStatus (NORMAL, CANCELLED) 개별 상품 상태 - 주문 생성 시 재고 차감 로직 - 주문 취소 시 재고 복구 (트랜잭션 경계 최적화: 복구 먼저) - Order Admin API (목록 조회, 상태 변경) - 주문 기간별 필터링 (OrderPeriod) - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../order/OrderAdminDetailInfo.java | 51 ++ .../application/order/OrderCommand.java | 20 + .../application/order/OrderDetailInfo.java | 49 ++ .../application/order/OrderFacade.java | 167 +++++ .../loopers/application/order/OrderInfo.java | 31 + .../application/order/OrderProductInfo.java | 33 + .../java/com/loopers/domain/order/Order.java | 160 ++++ .../com/loopers/domain/order/OrderPeriod.java | 23 + .../loopers/domain/order/OrderProduct.java | 66 ++ .../domain/order/OrderProductStatus.java | 9 + .../loopers/domain/order/OrderRepository.java | 18 + .../loopers/domain/order/OrderService.java | 60 ++ .../com/loopers/domain/order/OrderStatus.java | 11 + .../infrastructure/order/OrderEntity.java | 168 +++++ .../order/OrderJpaRepository.java | 24 + .../order/OrderProductEntity.java | 118 +++ .../order/OrderRepositoryImpl.java | 60 ++ .../api/order/OrderAdminV1ApiSpec.java | 41 ++ .../api/order/OrderAdminV1Controller.java | 61 ++ .../interfaces/api/order/OrderAdminV1Dto.java | 111 +++ .../interfaces/api/order/OrderV1ApiSpec.java | 53 ++ .../api/order/OrderV1Controller.java | 78 ++ .../interfaces/api/order/OrderV1Dto.java | 134 ++++ .../application/order/OrderFacadeTest.java | 465 ++++++++++++ .../domain/order/OrderProductTest.java | 147 ++++ .../domain/order/OrderServiceTest.java | 211 ++++++ .../com/loopers/domain/order/OrderTest.java | 522 +++++++++++++ .../api/order/OrderAdminV1ApiE2ETest.java | 455 ++++++++++++ .../api/order/OrderV1ApiE2ETest.java | 697 ++++++++++++++++++ http/order-admin-v1.http | 85 +++ http/order-v1.http | 62 ++ 31 files changed, 4190 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminDetailInfo.java 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/OrderDetailInfo.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/OrderProductInfo.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/OrderPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProduct.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProductStatus.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/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.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/OrderProductEntity.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/OrderAdminV1ApiSpec.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/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.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/OrderFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.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/OrderAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java create mode 100644 http/order-admin-v1.http create mode 100644 http/order-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminDetailInfo.java new file mode 100644 index 000000000..4e6da12e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminDetailInfo.java @@ -0,0 +1,51 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public record OrderAdminDetailInfo( + Long id, + Long memberId, + String orderNumber, + String orderName, + OrderStatus status, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + String shippingMemo, + Long totalAmount, + Long shippingFee, + Long discountAmount, + Long paymentAmount, + List orderProducts +) { + + public static OrderAdminDetailInfo from(Order order) { + List products = order.getOrderProducts().stream() + .map(OrderProductInfo::from) + .toList(); + + return new OrderAdminDetailInfo( + order.getId(), + order.getMemberId(), + order.getOrderNumber(), + order.getOrderName(), + order.getStatus(), + order.getRecipientName(), + order.getPhone(), + order.getZipCode(), + order.getAddress(), + order.getAddressDetail(), + order.getShippingMemo(), + order.getTotalAmount(), + order.getShippingFee(), + order.getDiscountAmount(), + order.getPaymentAmount(), + products + ); + } +} 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..d4c49748c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java @@ -0,0 +1,20 @@ +package com.loopers.application.order; + +import java.util.List; + +public class OrderCommand { + + public record Create( + Long addressId, + String shippingMemo, + List items + ) { + } + + public record OrderItem( + Long productId, + Long productOptionId, + int quantity + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java new file mode 100644 index 000000000..477effc63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java @@ -0,0 +1,49 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public record OrderDetailInfo( + Long id, + String orderNumber, + String orderName, + OrderStatus status, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + String shippingMemo, + Long totalAmount, + Long shippingFee, + Long discountAmount, + Long paymentAmount, + List orderProducts +) { + + public static OrderDetailInfo from(Order order) { + List products = order.getOrderProducts().stream() + .map(OrderProductInfo::from) + .toList(); + + return new OrderDetailInfo( + order.getId(), + order.getOrderNumber(), + order.getOrderName(), + order.getStatus(), + order.getRecipientName(), + order.getPhone(), + order.getZipCode(), + order.getAddress(), + order.getAddressDetail(), + order.getShippingMemo(), + order.getTotalAmount(), + order.getShippingFee(), + order.getDiscountAmount(), + order.getPaymentAmount(), + products + ); + } +} 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..146b3cfc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,167 @@ +package com.loopers.application.order; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderPeriod; +import com.loopers.domain.order.OrderProduct; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.ImageType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductService; +import com.loopers.support.auth.AdminValidator; +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; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final OrderService orderService; + private final MemberService memberService; + private final AddressService addressService; + private final ProductService productService; + private final AdminValidator adminValidator; + + @Transactional + public OrderDetailInfo createOrder(String loginId, String password, OrderCommand.Create command) { + Member member = memberService.authenticate(loginId, password); + + Address address = findAddressForMember(member.getId(), command.addressId()); + + Order order = new Order( + member.getId(), + address.getRecipientName(), + address.getPhone(), + address.getZipCode(), + address.getAddress(), + address.getAddressDetail(), + command.shippingMemo() + ); + + for (OrderCommand.OrderItem item : command.items()) { + Product product = productService.validateProduct(item.productId()); + ProductOption option = productService.getProductOption(item.productId(), item.productOptionId()); + + String thumbnailUrl = getThumbnailUrl(product); + + OrderProduct orderProduct = new OrderProduct( + item.productId(), + item.productOptionId(), + product.getName(), + option.getDisplayName(), + product.getBasePrice(), + option.getExtraPrice() != null ? option.getExtraPrice() : 0L, + item.quantity(), + thumbnailUrl + ); + order.addOrderProduct(orderProduct); + + productService.decreaseStock(item.productId(), item.productOptionId(), item.quantity()); + } + + Order savedOrder = orderService.createOrder(order); + return OrderDetailInfo.from(savedOrder); + } + + @Transactional(readOnly = true) + public List getOrders(String loginId, String password, OrderPeriod period) { + Member member = memberService.authenticate(loginId, password); + LocalDateTime startDate = period != null ? period.getStartDate() : null; + return orderService.getOrders(member.getId(), startDate) + .stream() + .map(OrderInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderDetailInfo getOrderDetail(String loginId, String password, Long orderId) { + Member member = memberService.authenticate(loginId, password); + Order order = orderService.getOrder(orderId); + orderService.validateOwnership(member.getId(), order); + return OrderDetailInfo.from(order); + } + + @Transactional + public OrderDetailInfo cancelOrder(String loginId, String password, Long orderId) { + Member member = memberService.authenticate(loginId, password); + Order order = orderService.getOrder(orderId); + orderService.validateOwnership(member.getId(), order); + + // 1. 재고 복구 (먼저 - 실패 시 전체 롤백) + for (OrderProduct orderProduct : order.getOrderProducts()) { + productService.increaseStock( + orderProduct.getProductId(), + orderProduct.getProductOptionId(), + orderProduct.getQuantity() + ); + } + + // 2. 주문 취소 (이후) + Order cancelledOrder = orderService.cancelOrder(orderId); + return OrderDetailInfo.from(cancelledOrder); + } + + @Transactional(readOnly = true) + public List getOrdersForAdmin(String ldap, OrderPeriod period) { + adminValidator.validate(ldap); + LocalDateTime startDate = period != null ? period.getStartDate() : null; + return orderService.getOrders(null, startDate) + .stream() + .map(OrderInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderAdminDetailInfo getOrderDetailForAdmin(String ldap, Long orderId) { + adminValidator.validate(ldap); + Order order = orderService.getOrder(orderId); + return OrderAdminDetailInfo.from(order); + } + + @Transactional + public OrderAdminDetailInfo changeOrderStatusForAdmin(String ldap, Long orderId, OrderStatus newStatus) { + adminValidator.validate(ldap); + Order order = orderService.getOrder(orderId); + + // 1. 취소 시 재고 복구 (먼저 - 실패 시 전체 롤백) + if (newStatus == OrderStatus.CANCELLED) { + for (OrderProduct op : order.getOrderProducts()) { + productService.increaseStock(op.getProductId(), op.getProductOptionId(), op.getQuantity()); + } + } + + // 2. 상태 변경 (이후) + Order updatedOrder = orderService.changeStatus(orderId, newStatus); + return OrderAdminDetailInfo.from(updatedOrder); + } + + private Address findAddressForMember(Long memberId, Long addressId) { + return addressService.getAddresses(memberId).stream() + .filter(address -> address.getId().equals(addressId)) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "배송지를 찾을 수 없습니다.")); + } + + private String getThumbnailUrl(Product product) { + if (product.getImages() == null || product.getImages().isEmpty()) { + return null; + } + return product.getImages().stream() + .filter(image -> image.getType() == ImageType.MAIN) + .findFirst() + .map(ProductImage::getUrl) + .orElse(product.getImages().get(0).getUrl()); + } +} 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..9e64035da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +public record OrderInfo( + Long id, + String orderNumber, + String orderName, + OrderStatus status, + Long totalAmount, + Long paymentAmount, + String thumbnailUrl +) { + + public static OrderInfo from(Order order) { + String thumbnailUrl = order.getOrderProducts().isEmpty() + ? null + : order.getOrderProducts().get(0).getThumbnailUrl(); + + return new OrderInfo( + order.getId(), + order.getOrderNumber(), + order.getOrderName(), + order.getStatus(), + order.getTotalAmount(), + order.getPaymentAmount(), + thumbnailUrl + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderProductInfo.java new file mode 100644 index 000000000..5b93d408b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderProductInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderProduct; +import com.loopers.domain.order.OrderProductStatus; + +public record OrderProductInfo( + Long id, + Long productId, + Long productOptionId, + String productName, + String optionValue, + Long price, + Long extraPrice, + int quantity, + String thumbnailUrl, + OrderProductStatus status +) { + + public static OrderProductInfo from(OrderProduct orderProduct) { + return new OrderProductInfo( + orderProduct.getId(), + orderProduct.getProductId(), + orderProduct.getProductOptionId(), + orderProduct.getProductName(), + orderProduct.getOptionValue(), + orderProduct.getPrice(), + orderProduct.getExtraPrice(), + orderProduct.getQuantity(), + orderProduct.getThumbnailUrl(), + orderProduct.getStatus() + ); + } +} 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..9f4605630 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,160 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +@Getter +public class Order { + + private Long id; + private Long memberId; + private String orderNumber; + private String orderName; + private String recipientName; + private String phone; + private String zipCode; + private String address; + private String addressDetail; + private String shippingMemo; + private OrderStatus status; + private Long totalAmount; + private Long shippingFee; + private Long discountAmount; + private Long paymentAmount; + private List orderProducts = new ArrayList<>(); + + public Order(Long memberId, String recipientName, String phone, String zipCode, + String address, String addressDetail, String shippingMemo) { + validateMemberId(memberId); + + this.memberId = memberId; + this.orderNumber = generateOrderNumber(); + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + this.shippingMemo = shippingMemo; + this.status = OrderStatus.PENDING; + this.totalAmount = 0L; + this.shippingFee = 0L; + this.discountAmount = 0L; + this.paymentAmount = 0L; + } + + public Order(Long id, Long memberId, String orderNumber, String orderName, + String recipientName, String phone, String zipCode, String address, + String addressDetail, String shippingMemo, OrderStatus status, + Long totalAmount, Long shippingFee, Long discountAmount, Long paymentAmount) { + this.id = id; + this.memberId = memberId; + this.orderNumber = orderNumber; + this.orderName = orderName; + this.recipientName = recipientName; + this.phone = phone; + this.zipCode = zipCode; + this.address = address; + this.addressDetail = addressDetail; + this.shippingMemo = shippingMemo; + this.status = status; + this.totalAmount = totalAmount; + this.shippingFee = shippingFee; + this.discountAmount = discountAmount; + this.paymentAmount = paymentAmount; + } + + public void addOrderProduct(OrderProduct orderProduct) { + this.orderProducts.add(orderProduct); + generateOrderName(); + calculateAmounts(); + } + + public void setShippingFee(Long shippingFee) { + this.shippingFee = shippingFee; + } + + public void setDiscountAmount(Long discountAmount) { + this.discountAmount = discountAmount; + } + + public void calculateAmounts() { + this.totalAmount = orderProducts.stream() + .mapToLong(OrderProduct::calculateTotalPrice) + .sum(); + this.paymentAmount = this.totalAmount + this.shippingFee - this.discountAmount; + } + + public boolean canCancel() { + return this.status == OrderStatus.PENDING || this.status == OrderStatus.PAID; + } + + public void cancel() { + if (!canCancel()) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.CANCELLED; + this.orderProducts.forEach(OrderProduct::cancel); + } + + public boolean canTransitionTo(OrderStatus newStatus) { + return switch (this.status) { + case PENDING -> newStatus == OrderStatus.PAID || newStatus == OrderStatus.CANCELLED; + case PAID -> newStatus == OrderStatus.PREPARING || newStatus == OrderStatus.CANCELLED; + case PREPARING -> newStatus == OrderStatus.SHIPPING; + case SHIPPING -> newStatus == OrderStatus.DELIVERED; + case DELIVERED -> newStatus == OrderStatus.RETURNED; + case CANCELLED, RETURNED -> false; + }; + } + + public void transitionTo(OrderStatus newStatus) { + if (!canTransitionTo(newStatus)) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("'%s' 상태에서 '%s' 상태로 변경할 수 없습니다.", this.status, newStatus)); + } + this.status = newStatus; + if (newStatus == OrderStatus.CANCELLED) { + this.orderProducts.forEach(OrderProduct::cancel); + } + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public void setOrderProducts(List orderProducts) { + this.orderProducts = orderProducts; + } + + private String generateOrderNumber() { + String datePrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + int randomSuffix = ThreadLocalRandom.current().nextInt(1000000, 10000000); + return "ORD" + datePrefix + "-" + randomSuffix; + } + + private void generateOrderName() { + if (orderProducts.isEmpty()) { + this.orderName = null; + return; + } + String firstName = orderProducts.get(0).getProductName(); + if (orderProducts.size() == 1) { + this.orderName = firstName; + } else { + this.orderName = firstName + " 외 " + (orderProducts.size() - 1) + "건"; + } + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPeriod.java new file mode 100644 index 000000000..e3832f8dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPeriod.java @@ -0,0 +1,23 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; + +public enum OrderPeriod { + THREE_MONTHS(3), + SIX_MONTHS(6), + ONE_YEAR(12), + ALL(null); + + private final Integer months; + + OrderPeriod(Integer months) { + this.months = months; + } + + public LocalDateTime getStartDate() { + if (months == null) { + return null; + } + return LocalDateTime.now().minusMonths(months); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProduct.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProduct.java new file mode 100644 index 000000000..96f11c2c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProduct.java @@ -0,0 +1,66 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.Getter; + +@Getter +public class OrderProduct { + + private Long id; + private Long productId; + private Long productOptionId; + private String productName; + private String optionValue; + private Long price; + private Long extraPrice; + private int quantity; + private String thumbnailUrl; + private OrderProductStatus status; + + public OrderProduct(Long productId, Long productOptionId, String productName, String optionValue, + Long price, Long extraPrice, int quantity, String thumbnailUrl) { + validateQuantity(quantity); + + this.productId = productId; + this.productOptionId = productOptionId; + this.productName = productName; + this.optionValue = optionValue; + this.price = price; + this.extraPrice = extraPrice; + this.quantity = quantity; + this.thumbnailUrl = thumbnailUrl; + this.status = OrderProductStatus.NORMAL; + } + + public OrderProduct(Long id, Long productId, Long productOptionId, String productName, String optionValue, + Long price, Long extraPrice, int quantity, String thumbnailUrl, OrderProductStatus status) { + this.id = id; + this.productId = productId; + this.productOptionId = productOptionId; + this.productName = productName; + this.optionValue = optionValue; + this.price = price; + this.extraPrice = extraPrice; + this.quantity = quantity; + this.thumbnailUrl = thumbnailUrl; + this.status = status; + } + + public Long calculateTotalPrice() { + return (price + extraPrice) * quantity; + } + + public void cancel() { + if (this.status == OrderProductStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 취소된 주문 상품입니다."); + } + this.status = OrderProductStatus.CANCELLED; + } + + private void validateQuantity(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1개 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProductStatus.java new file mode 100644 index 000000000..79ed21d89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderProductStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderProductStatus { + NORMAL, // 정상 + CANCEL_REQUESTED, // 취소 요청 + CANCELLED, // 취소 완료 + RETURN_REQUESTED, // 반품 요청 + RETURNED // 반품 완료 +} 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..c3e2ecb94 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findByMemberIdAndCreatedAtAfter(Long memberId, LocalDateTime startDate); + + List findByMemberId(Long memberId); + + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..fd554f203 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,60 @@ +package com.loopers.domain.order; + +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public List getOrders(Long memberId, LocalDateTime startDate) { + if (memberId == null) { + return orderRepository.findAll(); + } + if (startDate == null) { + return orderRepository.findByMemberId(memberId); + } + return orderRepository.findByMemberIdAndCreatedAtAfter(memberId, startDate); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Order createOrder(Order order) { + return orderRepository.save(order); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Order cancelOrder(Long orderId) { + Order order = getOrder(orderId); + order.cancel(); + return orderRepository.save(order); + } + + @Transactional(propagation = Propagation.REQUIRED) + public Order changeStatus(Long orderId, OrderStatus newStatus) { + Order order = getOrder(orderId); + order.transitionTo(newStatus); + return orderRepository.save(order); + } + + public void validateOwnership(Long memberId, Order order) { + if (!order.isOwnedBy(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "해당 주문에 대한 권한이 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..1d06029fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,11 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + PENDING, // 결제 대기 + PAID, // 결제 완료 + PREPARING, // 상품 준비중 + SHIPPING, // 배송중 + DELIVERED, // 배송 완료 + CANCELLED, // 주문 취소 + RETURNED // 반품 완료 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java new file mode 100644 index 000000000..e451272ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,168 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderProduct; +import com.loopers.domain.order.OrderStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "orders", + indexes = { + @Index(name = "idx_orders_member_id", columnList = "member_id"), + @Index(name = "idx_orders_order_number", columnList = "order_number") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "order_number", nullable = false, unique = true, length = 20) + private String orderNumber; + + @Column(name = "order_name", nullable = false, length = 100) + private String orderName; + + @Column(name = "recipient_name", nullable = false, length = 50) + private String recipientName; + + @Column(name = "recipient_phone", nullable = false, length = 20) + private String phone; + + @Column(name = "recipient_zip_code", length = 10) + private String zipCode; + + @Column(name = "recipient_address", nullable = false, length = 255) + private String address; + + @Column(name = "recipient_address_detail", length = 255) + private String addressDetail; + + @Column(name = "shipping_memo", length = 500) + private String shippingMemo; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "shipping_fee", nullable = false) + private Long shippingFee; + + @Column(name = "discount_amount", nullable = false) + private Long discountAmount; + + @Column(name = "payment_amount", nullable = false) + private Long paymentAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderProducts = new ArrayList<>(); + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public static OrderEntity from(Order order) { + OrderEntity entity = new OrderEntity(); + entity.memberId = order.getMemberId(); + entity.orderNumber = order.getOrderNumber(); + entity.orderName = order.getOrderName(); + entity.recipientName = order.getRecipientName(); + entity.phone = order.getPhone(); + entity.zipCode = order.getZipCode(); + entity.address = order.getAddress(); + entity.addressDetail = order.getAddressDetail(); + entity.shippingMemo = order.getShippingMemo(); + entity.status = order.getStatus(); + entity.totalAmount = order.getTotalAmount(); + entity.shippingFee = order.getShippingFee(); + entity.discountAmount = order.getDiscountAmount(); + entity.paymentAmount = order.getPaymentAmount(); + + if (order.getOrderProducts() != null) { + for (OrderProduct orderProduct : order.getOrderProducts()) { + entity.addOrderProduct(OrderProductEntity.from(orderProduct)); + } + } + + return entity; + } + + public void addOrderProduct(OrderProductEntity orderProduct) { + orderProducts.add(orderProduct); + orderProduct.setOrder(this); + } + + public Order toDomain() { + List domainOrderProducts = orderProducts.stream() + .map(OrderProductEntity::toDomain) + .toList(); + + Order order = new Order( + id, + memberId, + orderNumber, + orderName, + recipientName, + phone, + zipCode, + address, + addressDetail, + shippingMemo, + status, + totalAmount, + shippingFee, + discountAmount, + paymentAmount + ); + order.setOrderProducts(domainOrderProducts); + + return order; + } + + public void update(OrderStatus status) { + this.status = status; + } +} 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..fa256eb16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderEntity o LEFT JOIN FETCH o.orderProducts WHERE o.id = :id") + Optional findByIdWithOrderProducts(@Param("id") Long id); + + @Query("SELECT DISTINCT o FROM OrderEntity o LEFT JOIN FETCH o.orderProducts WHERE o.memberId = :memberId ORDER BY o.createdAt DESC") + List findByMemberIdWithOrderProducts(@Param("memberId") Long memberId); + + @Query("SELECT DISTINCT o FROM OrderEntity o LEFT JOIN FETCH o.orderProducts WHERE o.memberId = :memberId AND o.createdAt > :startDate ORDER BY o.createdAt DESC") + List findByMemberIdAndCreatedAtAfterWithOrderProducts(@Param("memberId") Long memberId, @Param("startDate") LocalDateTime startDate); + + @Query("SELECT DISTINCT o FROM OrderEntity o LEFT JOIN FETCH o.orderProducts ORDER BY o.createdAt DESC") + List findAllWithOrderProducts(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderProductEntity.java new file mode 100644 index 000000000..b138924c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderProductEntity.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderProduct; +import com.loopers.domain.order.OrderProductStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "order_products") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderProductEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + @Setter + private OrderEntity order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_option_id") + private Long productOptionId; + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + @Column(name = "option_value", length = 100) + private String optionValue; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "extra_price", nullable = false) + private Long extraPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Column(name = "thumbnail_url", length = 500) + private String thumbnailUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderProductStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public static OrderProductEntity from(OrderProduct orderProduct) { + OrderProductEntity entity = new OrderProductEntity(); + entity.productId = orderProduct.getProductId(); + entity.productOptionId = orderProduct.getProductOptionId(); + entity.productName = orderProduct.getProductName(); + entity.optionValue = orderProduct.getOptionValue(); + entity.price = orderProduct.getPrice(); + entity.extraPrice = orderProduct.getExtraPrice(); + entity.quantity = orderProduct.getQuantity(); + entity.thumbnailUrl = orderProduct.getThumbnailUrl(); + entity.status = orderProduct.getStatus(); + return entity; + } + + public OrderProduct toDomain() { + return new OrderProduct( + id, + productId, + productOptionId, + productName, + optionValue, + price, + extraPrice, + quantity, + thumbnailUrl, + status + ); + } + + public void updateStatus(OrderProductStatus status) { + this.status = status; + } +} 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..03ecafbd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,60 @@ +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; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + OrderEntity entity; + if (order.getId() != null) { + entity = orderJpaRepository.findById(order.getId()) + .orElseGet(() -> OrderEntity.from(order)); + entity.update(order.getStatus()); + } else { + entity = OrderEntity.from(order); + } + return orderJpaRepository.save(entity).toDomain(); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdWithOrderProducts(id) + .map(OrderEntity::toDomain); + } + + @Override + public List findByMemberIdAndCreatedAtAfter(Long memberId, LocalDateTime startDate) { + return orderJpaRepository.findByMemberIdAndCreatedAtAfterWithOrderProducts(memberId, startDate) + .stream() + .map(OrderEntity::toDomain) + .toList(); + } + + @Override + public List findByMemberId(Long memberId) { + return orderJpaRepository.findByMemberIdWithOrderProducts(memberId) + .stream() + .map(OrderEntity::toDomain) + .toList(); + } + + @Override + public List findAll() { + return orderJpaRepository.findAllWithOrderProducts() + .stream() + .map(OrderEntity::toDomain) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java new file mode 100644 index 000000000..abb8a9af5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.order.OrderPeriod; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Order Admin V1 API", description = "관리자용 주문 관리 API 입니다.") +public interface OrderAdminV1ApiSpec { + + @Operation( + summary = "[Admin] 주문 목록 조회", + description = "전체 주문 목록을 조회합니다. 관리자 권한이 필요합니다." + ) + ApiResponse> getOrders( + @Parameter(description = "LDAP 인증 헤더", required = true) String ldap, + @Parameter(description = "조회 기간") OrderPeriod period + ); + + @Operation( + summary = "[Admin] 주문 상세 조회", + description = "주문 상세 정보를 조회합니다. 관리자 권한이 필요합니다." + ) + ApiResponse getOrderDetail( + @Parameter(description = "LDAP 인증 헤더", required = true) String ldap, + @Parameter(description = "주문 ID", required = true) Long orderId + ); + + @Operation( + summary = "[Admin] 주문 상태 변경", + description = "주문 상태를 변경합니다. 관리자 권한이 필요합니다." + ) + ApiResponse changeOrderStatus( + @Parameter(description = "LDAP 인증 헤더", required = true) String ldap, + @Parameter(description = "주문 ID", required = true) Long orderId, + OrderAdminV1Dto.ChangeStatusRequest request + ); +} 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..f6d4a4016 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderAdminDetailInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderPeriod; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/orders") +public class OrderAdminV1Controller implements OrderAdminV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse> getOrders( + @RequestHeader("X-Loopers-Ldap") String ldap, + @RequestParam(required = false, defaultValue = "ALL") OrderPeriod period + ) { + List orders = orderFacade.getOrdersForAdmin(ldap, period); + List response = orders.stream() + .map(OrderAdminV1Dto.OrderAdminResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long orderId + ) { + OrderAdminDetailInfo info = orderFacade.getOrderDetailForAdmin(ldap, orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); + } + + @PatchMapping("/{orderId}/status") + @Override + public ApiResponse changeOrderStatus( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long orderId, + @RequestBody @Valid OrderAdminV1Dto.ChangeStatusRequest request + ) { + OrderAdminDetailInfo info = orderFacade.changeOrderStatusForAdmin(ldap, orderId, request.status()); + return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.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..c55675185 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,111 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderAdminDetailInfo; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderProductInfo; +import com.loopers.domain.order.OrderProductStatus; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderAdminV1Dto { + + public record ChangeStatusRequest( + @NotNull(message = "변경할 상태는 필수입니다.") + OrderStatus status + ) {} + + public record OrderAdminResponse( + Long id, + String orderNumber, + String orderName, + OrderStatus status, + Long totalAmount, + Long paymentAmount, + String thumbnailUrl + ) { + public static OrderAdminResponse from(OrderInfo info) { + return new OrderAdminResponse( + info.id(), + info.orderNumber(), + info.orderName(), + info.status(), + info.totalAmount(), + info.paymentAmount(), + info.thumbnailUrl() + ); + } + } + + public record OrderAdminDetailResponse( + Long id, + Long memberId, + String orderNumber, + String orderName, + OrderStatus status, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + String shippingMemo, + Long totalAmount, + Long shippingFee, + Long discountAmount, + Long paymentAmount, + List orderProducts + ) { + public static OrderAdminDetailResponse from(OrderAdminDetailInfo info) { + List products = info.orderProducts().stream() + .map(OrderProductResponse::from) + .toList(); + return new OrderAdminDetailResponse( + info.id(), + info.memberId(), + info.orderNumber(), + info.orderName(), + info.status(), + info.recipientName(), + info.phone(), + info.zipCode(), + info.address(), + info.addressDetail(), + info.shippingMemo(), + info.totalAmount(), + info.shippingFee(), + info.discountAmount(), + info.paymentAmount(), + products + ); + } + } + + public record OrderProductResponse( + Long id, + Long productId, + Long productOptionId, + String productName, + String optionValue, + Long price, + Long extraPrice, + int quantity, + String thumbnailUrl, + OrderProductStatus status + ) { + public static OrderProductResponse from(OrderProductInfo info) { + return new OrderProductResponse( + info.id(), + info.productId(), + info.productOptionId(), + info.productName(), + info.optionValue(), + info.price(), + info.extraPrice(), + info.quantity(), + info.thumbnailUrl(), + info.status() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..b4c787226 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.order.OrderPeriod; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Order V1 API", description = "주문 관리 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "새로운 주문을 생성합니다. 배송지와 주문 상품 정보가 필요합니다." + ) + ApiResponse createOrder( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + OrderV1Dto.CreateOrderRequest request + ); + + @Operation( + summary = "주문 목록 조회", + description = "회원의 주문 목록을 조회합니다. 기간 필터를 적용할 수 있습니다." + ) + ApiResponse> getOrders( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "조회 기간") OrderPeriod period + ); + + @Operation( + summary = "주문 상세 조회", + description = "주문 상세 정보를 조회합니다." + ) + ApiResponse getOrderDetail( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "주문 ID", required = true) Long orderId + ); + + @Operation( + summary = "주문 취소", + description = "주문을 취소합니다. 결제 대기 또는 결제 완료 상태에서만 취소 가능합니다." + ) + ApiResponse cancelOrder( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "주문 ID", required = true) Long orderId + ); +} 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..bdecf2eec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderPeriod; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderDetailInfo info = orderFacade.createOrder(loginId, password, request.toCommand()); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam(required = false, defaultValue = "THREE_MONTHS") OrderPeriod period + ) { + List orders = orderFacade.getOrders(loginId, password, period); + List response = orders.stream() + .map(OrderV1Dto.OrderResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long orderId + ) { + OrderDetailInfo info = orderFacade.getOrderDetail(loginId, password, orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } + + @PatchMapping("/{orderId}/cancel") + @Override + public ApiResponse cancelOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long orderId + ) { + OrderDetailInfo info = orderFacade.cancelOrder(loginId, password, orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.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..62d2b7fb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,134 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderCommand; +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderProductInfo; +import com.loopers.domain.order.OrderProductStatus; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + @NotNull(message = "배송지 ID는 필수입니다.") + Long addressId, + String shippingMemo, + @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.") + @Valid + List items + ) { + public OrderCommand.Create toCommand() { + List orderItems = items.stream() + .map(item -> new OrderCommand.OrderItem(item.productId(), item.productOptionId(), item.quantity())) + .toList(); + return new OrderCommand.Create(addressId, shippingMemo, orderItems); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + @NotNull(message = "상품 옵션 ID는 필수입니다.") + Long productOptionId, + @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") + int quantity + ) { + } + + public record OrderResponse( + Long id, + String orderNumber, + String orderName, + OrderStatus status, + Long totalAmount, + Long paymentAmount, + String thumbnailUrl + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.orderNumber(), + info.orderName(), + info.status(), + info.totalAmount(), + info.paymentAmount(), + info.thumbnailUrl() + ); + } + } + + public record OrderDetailResponse( + Long id, + String orderNumber, + String orderName, + OrderStatus status, + String recipientName, + String phone, + String zipCode, + String address, + String addressDetail, + String shippingMemo, + Long totalAmount, + Long shippingFee, + Long discountAmount, + Long paymentAmount, + List orderProducts + ) { + public static OrderDetailResponse from(OrderDetailInfo info) { + List products = info.orderProducts().stream() + .map(OrderProductResponse::from) + .toList(); + return new OrderDetailResponse( + info.id(), + info.orderNumber(), + info.orderName(), + info.status(), + info.recipientName(), + info.phone(), + info.zipCode(), + info.address(), + info.addressDetail(), + info.shippingMemo(), + info.totalAmount(), + info.shippingFee(), + info.discountAmount(), + info.paymentAmount(), + products + ); + } + } + + public record OrderProductResponse( + Long id, + Long productId, + Long productOptionId, + String productName, + String optionValue, + Long price, + Long extraPrice, + int quantity, + String thumbnailUrl, + OrderProductStatus status + ) { + public static OrderProductResponse from(OrderProductInfo info) { + return new OrderProductResponse( + info.id(), + info.productId(), + info.productOptionId(), + info.productName(), + info.optionValue(), + info.price(), + info.extraPrice(), + info.quantity(), + info.thumbnailUrl(), + info.status() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..f62aaf1dc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,465 @@ +package com.loopers.application.order; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderPeriod; +import com.loopers.domain.order.OrderProduct; +import com.loopers.domain.order.OrderProductStatus; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductStatus; +import com.loopers.support.auth.AdminValidator; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock + private OrderService orderService; + + @Mock + private MemberService memberService; + + @Mock + private AddressService addressService; + + @Mock + private ProductService productService; + + @Mock + private AdminValidator adminValidator; + + @InjectMocks + private OrderFacade orderFacade; + + private static final String LOGIN_ID = "testuser"; + private static final String PASSWORD = "Password123!"; + private static final Long MEMBER_ID = 1L; + private static final Long ADDRESS_ID = 1L; + private static final Long ORDER_ID = 1L; + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @Test + @DisplayName("인증 성공 후 주문을 생성한다") + void createsOrder_afterAuthentication() { + // arrange + Member member = createMember(); + Address address = createAddress(ADDRESS_ID, MEMBER_ID); + Product product = createProduct(1L, "테스트 상품", 10000L); + ProductOption option = createProductOption(10L, 1L, 1000L, 100); + Order savedOrder = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + + OrderCommand.Create command = new OrderCommand.Create( + ADDRESS_ID, + "문 앞에 놓아주세요", + List.of(new OrderCommand.OrderItem(1L, 10L, 2)) + ); + + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.getAddresses(MEMBER_ID)).willReturn(List.of(address)); + given(productService.validateProduct(1L)).willReturn(product); + given(productService.getProductOption(1L, 10L)).willReturn(option); + given(orderService.createOrder(any(Order.class))).willReturn(savedOrder); + + // act + OrderDetailInfo result = orderFacade.createOrder(LOGIN_ID, PASSWORD, command); + + // assert + assertAll( + () -> assertThat(result.id()).isEqualTo(ORDER_ID), + () -> verify(productService).decreaseStock(1L, 10L, 2) + ); + } + + @Test + @DisplayName("존재하지 않는 배송지로 주문하면 NOT_FOUND 예외가 발생한다") + void throwsException_whenAddressNotFound() { + // arrange + Member member = createMember(); + OrderCommand.Create command = new OrderCommand.Create( + 999L, null, List.of(new OrderCommand.OrderItem(1L, 10L, 1)) + ); + + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.getAddresses(MEMBER_ID)).willReturn(List.of()); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(LOGIN_ID, PASSWORD, command)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("판매중지 상품으로 주문하면 BAD_REQUEST 예외가 발생한다") + void throwsException_whenProductIsStopped() { + // arrange + Member member = createMember(); + Address address = createAddress(ADDRESS_ID, MEMBER_ID); + OrderCommand.Create command = new OrderCommand.Create( + ADDRESS_ID, null, List.of(new OrderCommand.OrderItem(1L, 10L, 1)) + ); + + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.getAddresses(MEMBER_ID)).willReturn(List.of(address)); + given(productService.validateProduct(1L)) + .willThrow(new CoreException(ErrorType.BAD_REQUEST, "판매중지된 상품입니다.")); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(LOGIN_ID, PASSWORD, command)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + void throwsException_whenInsufficientStock() { + // arrange + Member member = createMember(); + Address address = createAddress(ADDRESS_ID, MEMBER_ID); + Product product = createProduct(1L, "테스트 상품", 10000L); + ProductOption option = createProductOption(10L, 1L, 1000L, 100); + OrderCommand.Create command = new OrderCommand.Create( + ADDRESS_ID, null, List.of(new OrderCommand.OrderItem(1L, 10L, 200)) + ); + + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(addressService.getAddresses(MEMBER_ID)).willReturn(List.of(address)); + given(productService.validateProduct(1L)).willReturn(product); + given(productService.getProductOption(1L, 10L)).willReturn(option); + doThrow(new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.")) + .when(productService).decreaseStock(eq(1L), eq(10L), anyInt()); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(LOGIN_ID, PASSWORD, command)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 목록 조회") + @Nested + class GetOrders { + + @Test + @DisplayName("인증 성공 후 본인 주문 목록을 반환한다") + void returnsOrders_afterAuthentication() { + // arrange + Member member = createMember(); + Order order1 = createOrder(1L, MEMBER_ID, OrderStatus.PENDING); + Order order2 = createOrder(2L, MEMBER_ID, OrderStatus.PAID); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrders(eq(MEMBER_ID), any())).willReturn(List.of(order1, order2)); + + // act + List result = orderFacade.getOrders(LOGIN_ID, PASSWORD, OrderPeriod.THREE_MONTHS); + + // assert + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("기간 필터가 적용된다") + void appliesPeriodFilter() { + // arrange + Member member = createMember(); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrders(eq(MEMBER_ID), any())).willReturn(List.of()); + + // act + orderFacade.getOrders(LOGIN_ID, PASSWORD, OrderPeriod.SIX_MONTHS); + + // assert + verify(orderService).getOrders(eq(MEMBER_ID), any()); + } + } + + @DisplayName("주문 상세 조회") + @Nested + class GetOrderDetail { + + @Test + @DisplayName("인증 성공 후 본인 주문 상세를 반환한다") + void returnsOrderDetail_afterAuthentication() { + // arrange + Member member = createMember(); + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + + // act + OrderDetailInfo result = orderFacade.getOrderDetail(LOGIN_ID, PASSWORD, ORDER_ID); + + // assert + assertThat(result.id()).isEqualTo(ORDER_ID); + } + + @Test + @DisplayName("타인 주문 조회 시 FORBIDDEN 예외가 발생한다") + void throwsException_whenNotOwner() { + // arrange + Member member = createMember(); + Order order = createOrder(ORDER_ID, 2L, OrderStatus.PENDING); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + doThrow(new CoreException(ErrorType.FORBIDDEN, "해당 주문에 대한 권한이 없습니다.")) + .when(orderService).validateOwnership(MEMBER_ID, order); + + // act & assert + assertThatThrownBy(() -> orderFacade.getOrderDetail(LOGIN_ID, PASSWORD, ORDER_ID)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 주문 시 NOT_FOUND 예외가 발생한다") + void throwsException_whenOrderNotFound() { + // arrange + Member member = createMember(); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(999L)) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + // act & assert + assertThatThrownBy(() -> orderFacade.getOrderDetail(LOGIN_ID, PASSWORD, 999L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + + @Test + @DisplayName("인증 성공 후 주문을 취소하고 재고를 복구한다") + void cancelsOrderAndRestoresStock_afterAuthentication() { + // arrange + Member member = createMember(); + Order order = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + Order cancelledOrder = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.CANCELLED); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + given(orderService.cancelOrder(ORDER_ID)).willReturn(cancelledOrder); + + // act + OrderDetailInfo result = orderFacade.cancelOrder(LOGIN_ID, PASSWORD, ORDER_ID); + + // assert + assertAll( + () -> assertThat(result.status()).isEqualTo(OrderStatus.CANCELLED), + () -> verify(productService).increaseStock(1L, 10L, 2) + ); + } + + @Test + @DisplayName("타인 주문 취소 시 FORBIDDEN 예외가 발생한다") + void throwsException_whenNotOwner() { + // arrange + Member member = createMember(); + Order order = createOrder(ORDER_ID, 2L, OrderStatus.PENDING); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + doThrow(new CoreException(ErrorType.FORBIDDEN, "해당 주문에 대한 권한이 없습니다.")) + .when(orderService).validateOwnership(MEMBER_ID, order); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelOrder(LOGIN_ID, PASSWORD, ORDER_ID)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + } + + @Test + @DisplayName("취소 불가 상태 시 BAD_REQUEST 예외가 발생한다") + void throwsException_whenCannotCancel() { + // arrange + Member member = createMember(); + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PREPARING); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + given(orderService.cancelOrder(ORDER_ID)) + .willThrow(new CoreException(ErrorType.BAD_REQUEST, "취소할 수 없는 주문 상태입니다.")); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelOrder(LOGIN_ID, PASSWORD, ORDER_ID)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("재고 복구 실패 시 주문 취소도 롤백되어야 한다 - 재고 복구가 먼저 수행됨") + void rollsBackCancellation_whenStockRestoreFails() { + // arrange + Member member = createMember(); + Order order = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + given(memberService.authenticate(LOGIN_ID, PASSWORD)).willReturn(member); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + doThrow(new CoreException(ErrorType.INTERNAL_ERROR, "재고 복구 실패")) + .when(productService).increaseStock(eq(1L), eq(10L), eq(2)); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelOrder(LOGIN_ID, PASSWORD, ORDER_ID)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.INTERNAL_ERROR); + + // 재고 복구가 먼저 수행되므로 cancelOrder가 호출되지 않음 + verify(orderService, never()).cancelOrder(ORDER_ID); + } + } + + @DisplayName("Admin 주문 조회") + @Nested + class AdminOrders { + + private static final String ADMIN_LDAP = "loopers.admin"; + + @Test + @DisplayName("Admin이 주문 목록을 조회한다") + void returnsAllOrders_forAdmin() { + // arrange + Order order1 = createOrder(1L, MEMBER_ID, OrderStatus.PENDING); + Order order2 = createOrder(2L, 2L, OrderStatus.PAID); + doNothing().when(adminValidator).validate(ADMIN_LDAP); + given(orderService.getOrders(eq(null), any())).willReturn(List.of(order1, order2)); + + // act + List result = orderFacade.getOrdersForAdmin(ADMIN_LDAP, OrderPeriod.ALL); + + // assert + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("Admin이 주문 상세를 조회한다") + void returnsOrderDetail_forAdmin() { + // arrange + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + doNothing().when(adminValidator).validate(ADMIN_LDAP); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + + // act + OrderAdminDetailInfo result = orderFacade.getOrderDetailForAdmin(ADMIN_LDAP, ORDER_ID); + + // assert + assertAll( + () -> assertThat(result.id()).isEqualTo(ORDER_ID), + () -> assertThat(result.memberId()).isEqualTo(MEMBER_ID) + ); + } + + @Test + @DisplayName("Admin이 주문 상태를 CANCELLED로 변경하면 재고가 복구된다") + void restoresStock_whenAdminCancelsOrder() { + // arrange + Order order = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + Order cancelledOrder = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.CANCELLED); + doNothing().when(adminValidator).validate(ADMIN_LDAP); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + given(orderService.changeStatus(ORDER_ID, OrderStatus.CANCELLED)).willReturn(cancelledOrder); + + // act + OrderAdminDetailInfo result = orderFacade.changeOrderStatusForAdmin(ADMIN_LDAP, ORDER_ID, OrderStatus.CANCELLED); + + // assert + assertAll( + () -> assertThat(result.status()).isEqualTo(OrderStatus.CANCELLED), + () -> verify(productService).increaseStock(1L, 10L, 2) + ); + } + + @Test + @DisplayName("Admin 주문 취소 시 재고 복구 실패하면 상태 변경도 롤백된다 - 재고 복구가 먼저 수행됨") + void rollsBackStatusChange_whenStockRestoreFails() { + // arrange + Order order = createOrderWithProducts(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + doNothing().when(adminValidator).validate(ADMIN_LDAP); + given(orderService.getOrder(ORDER_ID)).willReturn(order); + doThrow(new CoreException(ErrorType.INTERNAL_ERROR, "재고 복구 실패")) + .when(productService).increaseStock(eq(1L), eq(10L), eq(2)); + + // act & assert + assertThatThrownBy(() -> orderFacade.changeOrderStatusForAdmin(ADMIN_LDAP, ORDER_ID, OrderStatus.CANCELLED)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.INTERNAL_ERROR); + + // 재고 복구가 먼저 수행되므로 changeStatus가 호출되지 않음 + verify(orderService, never()).changeStatus(ORDER_ID, OrderStatus.CANCELLED); + } + } + + private Member createMember() { + return new Member(MEMBER_ID, LOGIN_ID, "encodedPassword", "테스트", LocalDate.of(1990, 1, 1), "test@example.com"); + } + + private Address createAddress(Long id, Long memberId) { + return new Address(id, memberId, "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호", true); + } + + private Product createProduct(Long id, String name, Long price) { + return new Product(id, name, "PROD001", 1L, 1L, price, ProductStatus.SALE, null, null, 0L, List.of(), List.of(), null, null, null); + } + + private ProductOption createProductOption(Long id, Long productId, Long extraPrice, int stock) { + return new ProductOption(id, productId, "옵션1", "옵션1", extraPrice, stock, null, null, null); + } + + private Order createOrder(Long id, Long memberId, OrderStatus status) { + return new Order( + id, memberId, "ORD20250225-0000001", "테스트 주문", + "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호", "문 앞에 놓아주세요", + status, 10000L, 0L, 0L, 10000L + ); + } + + private Order createOrderWithProducts(Long id, Long memberId, OrderStatus status) { + Order order = createOrder(id, memberId, status); + OrderProductStatus productStatus = status == OrderStatus.CANCELLED + ? OrderProductStatus.CANCELLED + : OrderProductStatus.NORMAL; + OrderProduct orderProduct = new OrderProduct( + 1L, 1L, 10L, "테스트 상품", "옵션1", 5000L, 0L, 2, null, productStatus + ); + order.setOrderProducts(List.of(orderProduct)); + return order; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderProductTest.java new file mode 100644 index 000000000..d7251ee0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderProductTest.java @@ -0,0 +1,147 @@ +package com.loopers.domain.order; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderProductTest { + + @DisplayName("OrderProduct 생성") + @Nested + class Create { + + @Test + @DisplayName("유효한 입력으로 OrderProduct를 생성하면 NORMAL 상태로 생성된다") + void createsOrderProduct_withValidInputs() { + // arrange + Long productId = 1L; + Long productOptionId = 10L; + String productName = "테스트 상품"; + String optionValue = "옵션1"; + Long price = 10000L; + Long extraPrice = 1000L; + int quantity = 2; + String thumbnailUrl = "https://example.com/image.jpg"; + + // act + OrderProduct result = new OrderProduct( + productId, productOptionId, productName, optionValue, + price, extraPrice, quantity, thumbnailUrl + ); + + // assert + assertAll( + () -> assertThat(result.getProductId()).isEqualTo(productId), + () -> assertThat(result.getProductOptionId()).isEqualTo(productOptionId), + () -> assertThat(result.getProductName()).isEqualTo(productName), + () -> assertThat(result.getOptionValue()).isEqualTo(optionValue), + () -> assertThat(result.getPrice()).isEqualTo(price), + () -> assertThat(result.getExtraPrice()).isEqualTo(extraPrice), + () -> assertThat(result.getQuantity()).isEqualTo(quantity), + () -> assertThat(result.getThumbnailUrl()).isEqualTo(thumbnailUrl), + () -> assertThat(result.getStatus()).isEqualTo(OrderProductStatus.NORMAL) + ); + } + + @Test + @DisplayName("quantity가 0이면 예외가 발생한다") + void throwsException_whenQuantityIsZero() { + // act & assert + assertThatThrownBy(() -> new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 0L, 0, null + )) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("quantity가 음수이면 예외가 발생한다") + void throwsException_whenQuantityIsNegative() { + // act & assert + assertThatThrownBy(() -> new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 0L, -1, null + )) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("금액 계산") + @Nested + class CalculateTotalPrice { + + @Test + @DisplayName("총 금액은 (price + extraPrice) * quantity로 계산된다") + void calculatesTotalPrice() { + // arrange + OrderProduct orderProduct = new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 1000L, 3, null + ); + + // act + Long result = orderProduct.calculateTotalPrice(); + + // assert + assertThat(result).isEqualTo((10000L + 1000L) * 3); + } + + @Test + @DisplayName("extraPrice가 0인 경우에도 정상 계산된다") + void calculatesTotalPrice_whenExtraPriceIsZero() { + // arrange + OrderProduct orderProduct = new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 0L, 2, null + ); + + // act + Long result = orderProduct.calculateTotalPrice(); + + // assert + assertThat(result).isEqualTo(10000L * 2); + } + } + + @DisplayName("취소") + @Nested + class Cancel { + + @Test + @DisplayName("NORMAL 상태에서 취소하면 CANCELLED로 변경된다") + void cancels_whenStatusIsNormal() { + // arrange + OrderProduct orderProduct = new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 0L, 1, null + ); + + // act + orderProduct.cancel(); + + // assert + assertThat(orderProduct.getStatus()).isEqualTo(OrderProductStatus.CANCELLED); + } + + @Test + @DisplayName("이미 취소된 상태에서 재취소하면 예외가 발생한다") + void throwsException_whenAlreadyCancelled() { + // arrange + OrderProduct orderProduct = new OrderProduct( + 1L, 10L, "상품", "옵션", 10000L, 0L, 1, null + ); + orderProduct.cancel(); + + // act & assert + assertThatThrownBy(() -> orderProduct.cancel()) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..aecb05ffc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,211 @@ +package com.loopers.domain.order; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private OrderService orderService; + + private static final Long MEMBER_ID = 1L; + private static final Long ORDER_ID = 1L; + + @DisplayName("주문 조회") + @Nested + class GetOrder { + + @Test + @DisplayName("주문이 존재하면 반환한다") + void returnsOrder_whenExists() { + // arrange + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + given(orderRepository.findById(ORDER_ID)).willReturn(Optional.of(order)); + + // act + Order result = orderService.getOrder(ORDER_ID); + + // assert + assertThat(result.getId()).isEqualTo(ORDER_ID); + } + + @Test + @DisplayName("주문이 존재하지 않으면 NOT_FOUND 예외가 발생한다") + void throwsException_whenNotFound() { + // arrange + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> orderService.getOrder(999L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 목록 조회") + @Nested + class GetOrders { + + @Test + @DisplayName("memberId와 기간으로 주문 목록을 조회한다") + void returnsOrders_byMemberIdAndPeriod() { + // arrange + Order order1 = createOrder(1L, MEMBER_ID, OrderStatus.PENDING); + Order order2 = createOrder(2L, MEMBER_ID, OrderStatus.PAID); + LocalDateTime startDate = LocalDateTime.now().minusMonths(3); + given(orderRepository.findByMemberIdAndCreatedAtAfter(MEMBER_ID, startDate)) + .willReturn(List.of(order1, order2)); + + // act + List result = orderService.getOrders(MEMBER_ID, startDate); + + // assert + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("기간이 null이면 전체 기간을 조회한다") + void returnsAllOrders_whenPeriodIsNull() { + // arrange + Order order = createOrder(1L, MEMBER_ID, OrderStatus.PENDING); + given(orderRepository.findByMemberId(MEMBER_ID)).willReturn(List.of(order)); + + // act + List result = orderService.getOrders(MEMBER_ID, null); + + // assert + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("주문이 없으면 빈 목록을 반환한다") + void returnsEmptyList_whenNoOrders() { + // arrange + given(orderRepository.findByMemberId(MEMBER_ID)).willReturn(Collections.emptyList()); + + // act + List result = orderService.getOrders(MEMBER_ID, null); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @Test + @DisplayName("주문을 저장하고 반환한다") + void savesAndReturnsOrder() { + // arrange + Order order = new Order(MEMBER_ID, "홍길동", "010-1234-5678", null, "서울시", null, null); + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Order result = orderService.createOrder(order); + + // assert + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(MEMBER_ID), + () -> verify(orderRepository).save(order) + ); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + + @Test + @DisplayName("주문을 취소하고 반환한다") + void cancelsAndReturnsOrder() { + // arrange + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + given(orderRepository.findById(ORDER_ID)).willReturn(Optional.of(order)); + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // act + Order result = orderService.cancelOrder(ORDER_ID); + + // assert + assertAll( + () -> assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> verify(orderRepository).save(order) + ); + } + + @Test + @DisplayName("존재하지 않는 주문을 취소하면 NOT_FOUND 예외가 발생한다") + void throwsException_whenOrderNotFound() { + // arrange + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> orderService.cancelOrder(999L)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("소유권 검증") + @Nested + class ValidateOwnership { + + @Test + @DisplayName("본인 주문이면 예외가 발생하지 않는다") + void doesNotThrow_whenOwnerMatches() { + // arrange + Order order = createOrder(ORDER_ID, MEMBER_ID, OrderStatus.PENDING); + + // act & assert (no exception) + orderService.validateOwnership(MEMBER_ID, order); + } + + @Test + @DisplayName("타인 주문이면 FORBIDDEN 예외가 발생한다") + void throwsException_whenOwnerDoesNotMatch() { + // arrange + Order order = createOrder(ORDER_ID, 2L, OrderStatus.PENDING); + + // act & assert + assertThatThrownBy(() -> orderService.validateOwnership(MEMBER_ID, order)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.FORBIDDEN); + } + } + + private Order createOrder(Long id, Long memberId, OrderStatus status) { + return new Order( + id, memberId, "ORD20250225-0000001", "테스트 주문", + "홍길동", "010-1234-5678", null, "서울시", null, null, + status, 10000L, 0L, 0L, 10000L + ); + } +} 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..1a6f51073 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,522 @@ +package com.loopers.domain.order; + +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderTest { + + @DisplayName("Order 생성") + @Nested + class Create { + + @Test + @DisplayName("유효한 입력으로 Order를 생성하면 PENDING 상태로 생성된다") + void createsOrder_withValidInputs() { + // arrange + Long memberId = 1L; + String recipientName = "홍길동"; + String phone = "010-1234-5678"; + String zipCode = "06234"; + String address = "서울시 강남구"; + String addressDetail = "101호"; + String shippingMemo = "문 앞에 놔주세요"; + + // act + Order result = new Order(memberId, recipientName, phone, zipCode, address, addressDetail, shippingMemo); + + // assert + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(memberId), + () -> assertThat(result.getRecipientName()).isEqualTo(recipientName), + () -> assertThat(result.getPhone()).isEqualTo(phone), + () -> assertThat(result.getZipCode()).isEqualTo(zipCode), + () -> assertThat(result.getAddress()).isEqualTo(address), + () -> assertThat(result.getAddressDetail()).isEqualTo(addressDetail), + () -> assertThat(result.getShippingMemo()).isEqualTo(shippingMemo), + () -> assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING) + ); + } + + @Test + @DisplayName("주문번호가 ORD{YYYYMMDD}-{7자리} 형식으로 자동 생성된다") + void generatesOrderNumber_automaticallyWithCorrectFormat() { + // arrange & act + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + + // assert + assertThat(order.getOrderNumber()).matches("ORD\\d{8}-\\d{7}"); + } + + @Test + @DisplayName("memberId가 null이면 예외가 발생한다") + void throwsException_whenMemberIdIsNull() { + // act & assert + assertThatThrownBy(() -> new Order(null, "홍길동", "010-1234-5678", null, "서울시", null, null)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 추가 및 금액 계산") + @Nested + class AddOrderProduct { + + @Test + @DisplayName("addOrderProduct 시 orderProducts에 추가된다") + void addsOrderProduct() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct = new OrderProduct(1L, 10L, "상품1", "옵션1", 10000L, 0L, 1, null); + + // act + order.addOrderProduct(orderProduct); + + // assert + assertThat(order.getOrderProducts()).hasSize(1); + assertThat(order.getOrderProducts().get(0)).isEqualTo(orderProduct); + } + + @Test + @DisplayName("상품이 1개일 때 orderName은 상품명으로 생성된다") + void generatesOrderName_withSingleProduct() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct = new OrderProduct(1L, 10L, "테스트상품", "옵션1", 10000L, 0L, 1, null); + + // act + order.addOrderProduct(orderProduct); + + // assert + assertThat(order.getOrderName()).isEqualTo("테스트상품"); + } + + @Test + @DisplayName("상품이 2개 이상일 때 orderName은 '상품명 외 N건' 형식으로 생성된다") + void generatesOrderName_withMultipleProducts() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct1 = new OrderProduct(1L, 10L, "첫번째상품", "옵션1", 10000L, 0L, 1, null); + OrderProduct orderProduct2 = new OrderProduct(2L, 20L, "두번째상품", "옵션2", 20000L, 0L, 1, null); + + // act + order.addOrderProduct(orderProduct1); + order.addOrderProduct(orderProduct2); + + // assert + assertThat(order.getOrderName()).isEqualTo("첫번째상품 외 1건"); + } + + @Test + @DisplayName("totalAmount가 orderProducts 합계로 계산된다") + void calculatesTotalAmount() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct1 = new OrderProduct(1L, 10L, "상품1", "옵션1", 10000L, 1000L, 2, null); + OrderProduct orderProduct2 = new OrderProduct(2L, 20L, "상품2", "옵션2", 5000L, 0L, 3, null); + + // act + order.addOrderProduct(orderProduct1); + order.addOrderProduct(orderProduct2); + + // assert + // (10000 + 1000) * 2 + 5000 * 3 = 22000 + 15000 = 37000 + assertThat(order.getTotalAmount()).isEqualTo(37000); + } + + @Test + @DisplayName("paymentAmount = totalAmount + shippingFee - discountAmount") + void calculatesPaymentAmount() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct = new OrderProduct(1L, 10L, "상품1", "옵션1", 10000L, 0L, 1, null); + order.addOrderProduct(orderProduct); + order.setShippingFee(3000L); + order.setDiscountAmount(1000L); + + // act + order.calculateAmounts(); + + // assert + // 10000 + 3000 - 1000 = 12000 + assertThat(order.getPaymentAmount()).isEqualTo(12000L); + } + } + + @DisplayName("취소 가능 여부") + @Nested + class CanCancel { + + @Test + @DisplayName("PENDING 상태에서 canCancel()은 true") + void returnsTrue_whenPending() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + + // act & assert + assertThat(order.canCancel()).isTrue(); + } + + @Test + @DisplayName("PAID 상태에서 canCancel()은 true") + void returnsTrue_whenPaid() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act & assert + assertThat(order.canCancel()).isTrue(); + } + + @Test + @DisplayName("PREPARING 상태에서 canCancel()은 false") + void returnsFalse_whenPreparing() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PREPARING); + + // act & assert + assertThat(order.canCancel()).isFalse(); + } + + @Test + @DisplayName("SHIPPING 상태에서 canCancel()은 false") + void returnsFalse_whenShipping() { + // arrange + Order order = createOrderWithStatus(OrderStatus.SHIPPING); + + // act & assert + assertThat(order.canCancel()).isFalse(); + } + + @Test + @DisplayName("DELIVERED 상태에서 canCancel()은 false") + void returnsFalse_whenDelivered() { + // arrange + Order order = createOrderWithStatus(OrderStatus.DELIVERED); + + // act & assert + assertThat(order.canCancel()).isFalse(); + } + } + + @DisplayName("취소") + @Nested + class Cancel { + + @Test + @DisplayName("PENDING 상태에서 cancel() 시 CANCELLED로 변경된다") + void cancels_whenPending() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + + // act + order.cancel(); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("PAID 상태에서 cancel() 시 CANCELLED로 변경된다") + void cancels_whenPaid() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act + order.cancel(); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("PREPARING 상태에서 cancel() 시 예외가 발생한다") + void throwsException_whenPreparing() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PREPARING); + + // act & assert + assertThatThrownBy(() -> order.cancel()) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("cancel() 시 모든 OrderProduct도 CANCELLED로 변경된다") + void cancelsAllOrderProducts() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + OrderProduct orderProduct1 = new OrderProduct(1L, 10L, "상품1", "옵션1", 10000L, 0L, 1, null); + OrderProduct orderProduct2 = new OrderProduct(2L, 20L, "상품2", "옵션2", 20000L, 0L, 1, null); + order.addOrderProduct(orderProduct1); + order.addOrderProduct(orderProduct2); + + // act + order.cancel(); + + // assert + assertAll( + () -> assertThat(orderProduct1.getStatus()).isEqualTo(OrderProductStatus.CANCELLED), + () -> assertThat(orderProduct2.getStatus()).isEqualTo(OrderProductStatus.CANCELLED) + ); + } + } + + @DisplayName("소유권 검증") + @Nested + class Ownership { + + @Test + @DisplayName("본인 주문이면 isOwnedBy() true") + void returnsTrue_whenOwnerMatches() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + + // act & assert + assertThat(order.isOwnedBy(1L)).isTrue(); + } + + @Test + @DisplayName("타인 주문이면 isOwnedBy() false") + void returnsFalse_whenOwnerDoesNotMatch() { + // arrange + Order order = new Order(1L, "홍길동", "010-1234-5678", null, "서울시", null, null); + + // act & assert + assertThat(order.isOwnedBy(2L)).isFalse(); + } + } + + @DisplayName("상태 전환 가능 여부 (canTransitionTo)") + @Nested + class CanTransitionTo { + + @Test + @DisplayName("PENDING에서 PAID로 전환 가능") + void canTransition_fromPending_toPaid() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PENDING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.PAID)).isTrue(); + } + + @Test + @DisplayName("PENDING에서 CANCELLED로 전환 가능") + void canTransition_fromPending_toCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PENDING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.CANCELLED)).isTrue(); + } + + @Test + @DisplayName("PENDING에서 PREPARING으로 전환 불가") + void cannotTransition_fromPending_toPreparing() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PENDING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.PREPARING)).isFalse(); + } + + @Test + @DisplayName("PAID에서 PREPARING으로 전환 가능") + void canTransition_fromPaid_toPreparing() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.PREPARING)).isTrue(); + } + + @Test + @DisplayName("PAID에서 CANCELLED로 전환 가능") + void canTransition_fromPaid_toCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.CANCELLED)).isTrue(); + } + + @Test + @DisplayName("PAID에서 SHIPPING으로 직접 전환 불가") + void cannotTransition_fromPaid_toShipping() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.SHIPPING)).isFalse(); + } + + @Test + @DisplayName("PREPARING에서 SHIPPING으로 전환 가능") + void canTransition_fromPreparing_toShipping() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PREPARING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.SHIPPING)).isTrue(); + } + + @Test + @DisplayName("PREPARING에서 CANCELLED로 전환 불가") + void cannotTransition_fromPreparing_toCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PREPARING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.CANCELLED)).isFalse(); + } + + @Test + @DisplayName("SHIPPING에서 DELIVERED로 전환 가능") + void canTransition_fromShipping_toDelivered() { + // arrange + Order order = createOrderWithStatus(OrderStatus.SHIPPING); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.DELIVERED)).isTrue(); + } + + @Test + @DisplayName("DELIVERED에서 RETURNED로 전환 가능") + void canTransition_fromDelivered_toReturned() { + // arrange + Order order = createOrderWithStatus(OrderStatus.DELIVERED); + + // act & assert + assertThat(order.canTransitionTo(OrderStatus.RETURNED)).isTrue(); + } + + @Test + @DisplayName("CANCELLED에서는 어떤 상태로도 전환 불가") + void cannotTransition_fromCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.CANCELLED); + + // act & assert + assertAll( + () -> assertThat(order.canTransitionTo(OrderStatus.PENDING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.PAID)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.PREPARING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.SHIPPING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.DELIVERED)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.RETURNED)).isFalse() + ); + } + + @Test + @DisplayName("RETURNED에서는 어떤 상태로도 전환 불가") + void cannotTransition_fromReturned() { + // arrange + Order order = createOrderWithStatus(OrderStatus.RETURNED); + + // act & assert + assertAll( + () -> assertThat(order.canTransitionTo(OrderStatus.PENDING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.PAID)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.PREPARING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.SHIPPING)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.DELIVERED)).isFalse(), + () -> assertThat(order.canTransitionTo(OrderStatus.CANCELLED)).isFalse() + ); + } + } + + @DisplayName("상태 전환 (transitionTo)") + @Nested + class TransitionTo { + + @Test + @DisplayName("유효한 상태 전환 시 상태가 변경된다") + void changesStatus_whenValidTransition() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PAID); + + // act + order.transitionTo(OrderStatus.PREPARING); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.PREPARING); + } + + @Test + @DisplayName("유효하지 않은 상태 전환 시 예외가 발생한다") + void throwsException_whenInvalidTransition() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PENDING); + + // act & assert + assertThatThrownBy(() -> order.transitionTo(OrderStatus.SHIPPING)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("CANCELLED로 전환 시 모든 OrderProduct도 취소된다") + void cancelsAllOrderProducts_whenTransitionToCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.PENDING); + OrderProduct orderProduct1 = new OrderProduct(1L, 10L, "상품1", "옵션1", 10000L, 0L, 1, null); + OrderProduct orderProduct2 = new OrderProduct(2L, 20L, "상품2", "옵션2", 20000L, 0L, 1, null); + order.setOrderProducts(List.of(orderProduct1, orderProduct2)); + + // act + order.transitionTo(OrderStatus.CANCELLED); + + // assert + assertAll( + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> assertThat(orderProduct1.getStatus()).isEqualTo(OrderProductStatus.CANCELLED), + () -> assertThat(orderProduct2.getStatus()).isEqualTo(OrderProductStatus.CANCELLED) + ); + } + + @Test + @DisplayName("CANCELLED 상태에서 전환 시도하면 예외가 발생한다") + void throwsException_whenTransitionFromCancelled() { + // arrange + Order order = createOrderWithStatus(OrderStatus.CANCELLED); + + // act & assert + assertThatThrownBy(() -> order.transitionTo(OrderStatus.PAID)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("RETURNED 상태에서 전환 시도하면 예외가 발생한다") + void throwsException_whenTransitionFromReturned() { + // arrange + Order order = createOrderWithStatus(OrderStatus.RETURNED); + + // act & assert + assertThatThrownBy(() -> order.transitionTo(OrderStatus.CANCELLED)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + private Order createOrderWithStatus(OrderStatus status) { + return new Order( + null, 1L, "ORD20250225-0000001", "테스트 주문", + "홍길동", "010-1234-5678", null, "서울시", null, null, + status, 10000L, 0L, 0L, 10000L + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java new file mode 100644 index 000000000..c55d00590 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java @@ -0,0 +1,455 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressRepository; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +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) +class OrderAdminV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final AddressRepository addressRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_LDAP = "loopers.admin"; + + @Autowired + public OrderAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + AddressRepository addressRepository, + ProductRepository productRepository, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.addressRepository = addressRepository; + this.productRepository = productRepository; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/admin/orders - Admin 주문 목록 조회") + @Nested + class GetOrdersForAdmin { + + private Member member; + private Address address; + private Product product; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + createOrderForTest(); + } + + @Test + @DisplayName("관리자 목록 조회 시 200 OK를 반환한다") + void returnsOk_whenAdmin() { + // act + ResponseEntity>> response = getOrdersForAdmin(ADMIN_LDAP); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1) + ); + } + + @Test + @DisplayName("비관리자 조회 시 403 Forbidden을 반환한다") + void returnsForbidden_whenNotAdmin() { + // act + ResponseEntity> response = getOrdersForAdminWithError("invalid.ldap"); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private void createOrderForTest() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", member.getLoginId()); + headers.set("X-Loopers-LoginPw", "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity>> getOrdersForAdmin(String ldap) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + return testRestTemplate.exchange( + "/api/v1/admin/orders", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getOrdersForAdminWithError(String ldap) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + return testRestTemplate.exchange( + "/api/v1/admin/orders", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("GET /api/v1/admin/orders/{orderId} - Admin 주문 상세 조회") + @Nested + class GetOrderDetailForAdmin { + + private Member member; + private Address address; + private Product product; + private Long orderId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + orderId = createOrderAndGetId(); + } + + @Test + @DisplayName("관리자 상세 조회 시 200 OK를 반환한다") + void returnsOk_whenAdmin() { + // act + ResponseEntity> response = getOrderDetailForAdmin( + ADMIN_LDAP, orderId + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().memberId()).isEqualTo(member.getId()) + ); + } + + @Test + @DisplayName("비관리자 조회 시 403 Forbidden을 반환한다") + void returnsForbidden_whenNotAdmin() { + // act + ResponseEntity> response = getOrderDetailForAdminWithError("invalid.ldap", orderId); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private Long createOrderAndGetId() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", member.getLoginId()); + headers.set("X-Loopers-LoginPw", "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private ResponseEntity> getOrderDetailForAdmin( + String ldap, Long orderId + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + return testRestTemplate.exchange( + "/api/v1/admin/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getOrderDetailForAdminWithError(String ldap, Long orderId) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + return testRestTemplate.exchange( + "/api/v1/admin/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("PATCH /api/v1/admin/orders/{orderId}/status - Admin 주문 상태 변경") + @Nested + class ChangeOrderStatusForAdmin { + + private Member member; + private Address address; + private Product product; + private Long orderId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + orderId = createOrderAndGetId(); + } + + @Test + @DisplayName("관리자 상태 변경 성공 시 200 OK를 반환한다") + void returnsOk_whenAdminChangesStatus() { + // arrange + payOrder(orderId); + OrderAdminV1Dto.ChangeStatusRequest request = new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.PREPARING); + + // act + ResponseEntity> response = changeOrderStatus( + ADMIN_LDAP, orderId, request + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PREPARING) + ); + } + + @Test + @DisplayName("비관리자 요청 시 403 Forbidden을 반환한다") + void returnsForbidden_whenNotAdmin() { + // arrange + OrderAdminV1Dto.ChangeStatusRequest request = new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.PAID); + + // act + ResponseEntity> response = changeOrderStatusWithError( + "invalid.ldap", orderId, request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("잘못된 상태 전환 요청 시 400 Bad Request를 반환한다") + void returnsBadRequest_whenInvalidStatusTransition() { + // arrange - PENDING 상태에서 바로 SHIPPING으로 변경 시도 + OrderAdminV1Dto.ChangeStatusRequest request = new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.SHIPPING); + + // act + ResponseEntity> response = changeOrderStatusWithError( + ADMIN_LDAP, orderId, request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("CANCELLED로 상태 변경 시 재고가 복구된다") + void restoresStock_whenStatusChangedToCancelled() { + // arrange + int initialStock = product.getOptions().get(0).getStockQuantity(); + int orderedQuantity = 1; + int stockAfterOrder = initialStock - orderedQuantity; + + // 주문 후 재고 확인 + Product productAfterOrder = productRepository.findById(product.getId()).orElseThrow(); + assertThat(productAfterOrder.getOptions().get(0).getStockQuantity()).isEqualTo(stockAfterOrder); + + OrderAdminV1Dto.ChangeStatusRequest request = new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.CANCELLED); + + // act + ResponseEntity> response = changeOrderStatus( + ADMIN_LDAP, orderId, request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.CANCELLED); + + // 재고 복구 확인 + Product productAfterCancel = productRepository.findById(product.getId()).orElseThrow(); + assertThat(productAfterCancel.getOptions().get(0).getStockQuantity()).isEqualTo(initialStock); + } + + @Test + @DisplayName("RETURNED로 상태 변경 시 재고는 복구되지 않는다") + void doesNotRestoreStock_whenStatusChangedToReturned() { + // arrange - 주문 진행 (PENDING -> PAID -> PREPARING -> SHIPPING -> DELIVERED -> RETURNED) + payOrder(orderId); + changeOrderStatus(ADMIN_LDAP, orderId, new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.PREPARING)); + changeOrderStatus(ADMIN_LDAP, orderId, new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.SHIPPING)); + changeOrderStatus(ADMIN_LDAP, orderId, new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.DELIVERED)); + + int stockBeforeReturn = productRepository.findById(product.getId()).orElseThrow() + .getOptions().get(0).getStockQuantity(); + + OrderAdminV1Dto.ChangeStatusRequest request = new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.RETURNED); + + // act + ResponseEntity> response = changeOrderStatus( + ADMIN_LDAP, orderId, request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.RETURNED); + + // 재고 변화 없음 확인 + Product productAfterReturn = productRepository.findById(product.getId()).orElseThrow(); + assertThat(productAfterReturn.getOptions().get(0).getStockQuantity()).isEqualTo(stockBeforeReturn); + } + + private Long createOrderAndGetId() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", member.getLoginId()); + headers.set("X-Loopers-LoginPw", "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void payOrder(Long orderId) { + // PENDING -> PAID 상태 변경 (실제로는 결제 로직이 있지만, 테스트에서는 Admin API로 변경) + changeOrderStatus(ADMIN_LDAP, orderId, new OrderAdminV1Dto.ChangeStatusRequest(OrderStatus.PAID)); + } + + private ResponseEntity> changeOrderStatus( + String ldap, Long orderId, OrderAdminV1Dto.ChangeStatusRequest request + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + headers.setContentType(MediaType.APPLICATION_JSON); + return testRestTemplate.exchange( + "/api/v1/admin/orders/" + orderId + "/status", + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> changeOrderStatusWithError( + String ldap, Long orderId, OrderAdminV1Dto.ChangeStatusRequest request + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", ldap); + headers.setContentType(MediaType.APPLICATION_JSON); + return testRestTemplate.exchange( + "/api/v1/admin/orders/" + orderId + "/status", + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "Test User", + LocalDate.of(1990, 1, 1), loginId + "@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private Address saveAddress(Long memberId) { + Address address = new Address(memberId, "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호"); + return addressRepository.save(address); + } + + private Brand saveBrand(String name) { + Brand brand = new Brand(name, "Description", "https://example.com/logo.png"); + return brandRepository.save(brand); + } + + private Category saveCategory(String name) { + Category category = new Category(name); + return categoryRepository.save(category); + } + + private Product saveProductWithOption(String name, Long brandId, Long categoryId, Long basePrice, ProductOption option) { + Product product = new Product(name, brandId, categoryId, basePrice, List.of(option), List.of()); + return productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..f736ab5d2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -0,0 +1,697 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressRepository; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductStatus; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +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) +class OrderV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final AddressRepository addressRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + AddressRepository addressRepository, + ProductRepository productRepository, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.addressRepository = addressRepository; + this.productRepository = productRepository; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/orders - 주문 생성") + @Nested + class CreateOrder { + + private Member member; + private Address address; + private Product product; + private ProductOption option; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + } + + @Test + @DisplayName("주문 생성 시 201 Created를 반환한다") + void returnsCreated_whenCreateOrder() { + // arrange + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), + "문 앞에 놓아주세요", + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 2)) + ); + + // act + ResponseEntity> response = createOrder( + member.getLoginId(), "Password123!", request + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING), + () -> assertThat(response.getBody().data().orderProducts()).hasSize(1) + ); + } + + @Test + @DisplayName("인증 실패 시 401 Unauthorized를 반환한다") + void returnsUnauthorized_whenAuthenticationFails() { + // arrange + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + + // act + ResponseEntity> response = createOrderWithError( + member.getLoginId(), "WrongPassword!", request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("존재하지 않는 배송지로 주문하면 404 Not Found를 반환한다") + void returnsNotFound_whenAddressNotExists() { + // arrange + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + 999L, null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + + // act + ResponseEntity> response = createOrderWithError( + member.getLoginId(), "Password123!", request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("재고 부족 시 400 Bad Request를 반환한다") + void returnsBadRequest_whenInsufficientStock() { + // arrange + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 200)) + ); + + // act + ResponseEntity> response = createOrderWithError( + member.getLoginId(), "Password123!", request + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private ResponseEntity> createOrder( + String loginId, String password, OrderV1Dto.CreateOrderRequest request + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + return testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> createOrderWithError( + String loginId, String password, OrderV1Dto.CreateOrderRequest request + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + return testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("GET /api/v1/orders - 주문 목록 조회") + @Nested + class GetOrders { + + private Member member; + private Address address; + private Product product; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + } + + @Test + @DisplayName("주문 목록을 조회하면 200 OK를 반환한다") + void returnsOk_whenGetOrders() { + // arrange + createOrderForTest(); + + // act + ResponseEntity>> response = getOrders( + member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1) + ); + } + + @Test + @DisplayName("주문이 없으면 빈 목록을 반환한다") + void returnsEmptyList_whenNoOrders() { + // act + ResponseEntity>> response = getOrders( + member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + + private void createOrderForTest() { + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private ResponseEntity>> getOrders(String loginId, String password) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("GET /api/v1/orders/{orderId} - 주문 상세 조회") + @Nested + class GetOrderDetail { + + private Member member; + private Address address; + private Product product; + private Long orderId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + orderId = createOrderAndGetId(); + } + + @Test + @DisplayName("본인 주문 조회 시 200 OK를 반환한다") + void returnsOk_whenGetOwnOrder() { + // act + ResponseEntity> response = getOrderDetail( + orderId, member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(orderId) + ); + } + + @Test + @DisplayName("타인 주문 조회 시 403 Forbidden을 반환한다") + void returnsForbidden_whenNotOwner() { + // arrange + Member otherMember = saveMember("user2", "Password123!"); + + // act + ResponseEntity> response = getOrderDetailWithError( + orderId, otherMember.getLoginId(), "Password123!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 시 404 Not Found를 반환한다") + void returnsNotFound_whenOrderNotExists() { + // act + ResponseEntity> response = getOrderDetailWithError( + 999L, member.getLoginId(), "Password123!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private Long createOrderAndGetId() { + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private ResponseEntity> getOrderDetail( + Long orderId, String loginId, String password + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + + private ResponseEntity> getOrderDetailWithError( + Long orderId, String loginId, String password + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("PATCH /api/v1/orders/{orderId}/cancel - 주문 취소") + @Nested + class CancelOrder { + + private Member member; + private Address address; + private Product product; + private Long orderId; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + Brand brand = saveBrand("Nike"); + Category category = saveCategory("의류"); + ProductOption option = new ProductOption(null, "M", "M 사이즈", 1000L, 100); + product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + orderId = createOrderAndGetId(); + } + + @Test + @DisplayName("PENDING 상태 취소 시 200 OK를 반환한다") + void returnsOk_whenCancelPendingOrder() { + // act + ResponseEntity> response = cancelOrder( + orderId, member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.CANCELLED) + ); + } + + @Test + @DisplayName("취소 후 재고가 복구된다") + void restoresStock_afterCancel() { + // arrange + // Note: @BeforeEach already created an order with quantity=1, so stock is 99 at this point + Product currentProduct = productRepository.findById(product.getId()).orElseThrow(); + int stockBeforeNewOrder = currentProduct.getOptions().get(0).getStockQuantity(); + int orderedQuantity = 5; + + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), orderedQuantity)) + ); + ResponseEntity> createResponse = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + Long newOrderId = createResponse.getBody().data().id(); + + // act + cancelOrder(newOrderId, member.getLoginId(), "Password123!"); + + // assert + Product updatedProduct = productRepository.findById(product.getId()).orElseThrow(); + int finalStock = updatedProduct.getOptions().stream() + .filter(o -> o.getId().equals(product.getOptions().get(0).getId())) + .findFirst() + .map(ProductOption::getStockQuantity) + .orElse(0); + assertThat(finalStock).isEqualTo(stockBeforeNewOrder); + } + + private Long createOrderAndGetId() { + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), product.getOptions().get(0).getId(), 1)) + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private ResponseEntity> cancelOrder( + Long orderId, String loginId, String password + ) { + HttpHeaders headers = createAuthHeaders(loginId, password); + return testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.PATCH, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + } + } + + @DisplayName("주문-재고 연계 및 SOLDOUT 상태 전환") + @Nested + class StockAndSoldoutIntegration { + + private Member member; + private Address address; + private Brand brand; + private Category category; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + address = saveAddress(member.getId()); + brand = saveBrand("Nike"); + category = saveCategory("의류"); + } + + @Test + @DisplayName("재고가 전부 소진되면 상품이 SOLDOUT 상태로 전환된다") + void changesProductStatusToSoldout_whenStockIsExhaustedByOrder() { + // Arrange: 재고가 딱 5개인 상품 생성 + ProductOption option = new ProductOption(null, "M", "M 사이즈", 0L, 5); + Product product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + Long optionId = product.getOptions().get(0).getId(); + + // 상품이 SALE 상태인지 확인 + Product beforeProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(beforeProduct.getStatus()).isEqualTo(ProductStatus.SALE); + + // Act: 재고 전체를 주문 + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), optionId, 5)) + ); + + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + + // Assert: 상품이 SOLDOUT 상태로 변경되었는지 확인 + Product afterProduct = productRepository.findById(product.getId()).orElseThrow(); + assertAll( + () -> assertThat(afterProduct.getStatus()).isEqualTo(ProductStatus.SOLDOUT), + () -> assertThat(afterProduct.getTotalStockQuantity()).isEqualTo(0) + ); + } + + @Test + @DisplayName("다중 옵션 상품의 모든 옵션 재고가 소진되면 SOLDOUT 상태로 전환된다") + void changesProductStatusToSoldout_whenAllOptionsExhausted() { + // Arrange: 2개의 옵션을 가진 상품 생성 (M: 재고 3, L: 재고 2) + ProductOption optionM = new ProductOption(null, "M", "M 사이즈", 0L, 3); + ProductOption optionL = new ProductOption(null, "L", "L 사이즈", 1000L, 2); + Product product = new Product("테스트 상품", brand.getId(), category.getId(), 10000L, + List.of(optionM, optionL), List.of()); + product = productRepository.save(product); + + Long optionMId = product.getOptions().get(0).getId(); + Long optionLId = product.getOptions().get(1).getId(); + + // 상품이 SALE 상태인지 확인 + assertThat(product.getStatus()).isEqualTo(ProductStatus.SALE); + + // Act: M 사이즈 전체 주문 + OrderV1Dto.CreateOrderRequest requestM = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), optionMId, 3)) + ); + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(requestM, headers), + new ParameterizedTypeReference>() {} + ); + + // M 사이즈만 소진된 상태에서는 SALE 유지 + Product afterMOrder = productRepository.findById(product.getId()).orElseThrow(); + assertThat(afterMOrder.getStatus()).isEqualTo(ProductStatus.SALE); + + // Act: L 사이즈 전체 주문 + OrderV1Dto.CreateOrderRequest requestL = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), optionLId, 2)) + ); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(requestL, headers), + new ParameterizedTypeReference>() {} + ); + + // Assert: 모든 옵션 재고가 소진되어 SOLDOUT 상태로 변경 + Product afterAllOrder = productRepository.findById(product.getId()).orElseThrow(); + assertAll( + () -> assertThat(afterAllOrder.getStatus()).isEqualTo(ProductStatus.SOLDOUT), + () -> assertThat(afterAllOrder.getTotalStockQuantity()).isEqualTo(0) + ); + } + + @Test + @DisplayName("재고가 남아있으면 SALE 상태를 유지한다") + void keepsProductStatusSale_whenStockRemains() { + // Arrange: 재고가 10개인 상품 생성 + ProductOption option = new ProductOption(null, "M", "M 사이즈", 0L, 10); + Product product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + Long optionId = product.getOptions().get(0).getId(); + + // Act: 재고의 일부만 주문 (5개) + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), optionId, 5)) + ); + + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + + // Assert: 상품이 SALE 상태를 유지하는지 확인 + Product afterProduct = productRepository.findById(product.getId()).orElseThrow(); + assertAll( + () -> assertThat(afterProduct.getStatus()).isEqualTo(ProductStatus.SALE), + () -> assertThat(afterProduct.getTotalStockQuantity()).isEqualTo(5) + ); + } + + @Test + @DisplayName("SOLDOUT 상품의 주문이 취소되면 SALE 상태로 복구된다") + void changesProductStatusToSale_whenSoldoutOrderCancelled() { + // Arrange: 재고가 딱 5개인 상품 생성 + ProductOption option = new ProductOption(null, "M", "M 사이즈", 0L, 5); + Product product = saveProductWithOption("테스트 상품", brand.getId(), category.getId(), 10000L, option); + Long optionId = product.getOptions().get(0).getId(); + + // 재고 전체를 주문하여 SOLDOUT 만들기 + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + address.getId(), null, + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), optionId, 5)) + ); + + HttpHeaders headers = createAuthHeaders(member.getLoginId(), "Password123!"); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity> createResponse = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + Long orderId = createResponse.getBody().data().id(); + + // SOLDOUT 상태 확인 + Product soldoutProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(soldoutProduct.getStatus()).isEqualTo(ProductStatus.SOLDOUT); + + // Act: 주문 취소 + testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.PATCH, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + // Assert: 상품이 SALE 상태로 복구되고 재고도 복구되었는지 확인 + Product afterCancelProduct = productRepository.findById(product.getId()).orElseThrow(); + assertAll( + () -> assertThat(afterCancelProduct.getStatus()).isEqualTo(ProductStatus.SALE), + () -> assertThat(afterCancelProduct.getTotalStockQuantity()).isEqualTo(5) + ); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "Test User", + LocalDate.of(1990, 1, 1), loginId + "@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private Address saveAddress(Long memberId) { + Address address = new Address(memberId, "홍길동", "010-1234-5678", "06234", "서울시 강남구", "101호"); + return addressRepository.save(address); + } + + private Brand saveBrand(String name) { + Brand brand = new Brand(name, "Description", "https://example.com/logo.png"); + return brandRepository.save(brand); + } + + private Category saveCategory(String name) { + Category category = new Category(name); + return categoryRepository.save(category); + } + + private Product saveProductWithOption(String name, Long brandId, Long categoryId, Long basePrice, ProductOption option) { + Product product = new Product(name, brandId, categoryId, basePrice, List.of(option), List.of()); + return productRepository.save(product); + } +} diff --git a/http/order-admin-v1.http b/http/order-admin-v1.http new file mode 100644 index 000000000..4b8f66bbc --- /dev/null +++ b/http/order-admin-v1.http @@ -0,0 +1,85 @@ +### Order Admin V1 API - 주문 관리 API 테스트 + +### 환경 변수 +@baseUrl = http://localhost:8080 +@adminLdap = loopers.admin + +### ===== 주문 목록 조회 ===== + +### [Admin] 주문 목록 조회 (전체 기간) +GET {{baseUrl}}/api/v1/admin/orders?period=ALL +X-Loopers-Ldap: {{adminLdap}} + +### [Admin] 주문 목록 조회 (3개월) +GET {{baseUrl}}/api/v1/admin/orders?period=THREE_MONTHS +X-Loopers-Ldap: {{adminLdap}} + +### [Admin] 주문 목록 조회 (6개월) +GET {{baseUrl}}/api/v1/admin/orders?period=SIX_MONTHS +X-Loopers-Ldap: {{adminLdap}} + +### [Admin] 주문 목록 조회 (1년) +GET {{baseUrl}}/api/v1/admin/orders?period=ONE_YEAR +X-Loopers-Ldap: {{adminLdap}} + +### ===== 주문 상세 조회 ===== + +### [Admin] 주문 상세 조회 +GET {{baseUrl}}/api/v1/admin/orders/1 +X-Loopers-Ldap: {{adminLdap}} + +### ===== 주문 상태 변경 ===== + +### [Admin] 주문 상태 변경 - PAID (결제 완료) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "PAID" +} + +### [Admin] 주문 상태 변경 - PREPARING (상품 준비중) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "PREPARING" +} + +### [Admin] 주문 상태 변경 - SHIPPING (배송중) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "SHIPPING" +} + +### [Admin] 주문 상태 변경 - DELIVERED (배송 완료) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "DELIVERED" +} + +### [Admin] 주문 상태 변경 - CANCELLED (주문 취소 - 재고 복구됨) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "CANCELLED" +} + +### [Admin] 주문 상태 변경 - RETURNED (반품 - 재고 복구 안됨) +PATCH {{baseUrl}}/api/v1/admin/orders/1/status +Content-Type: application/json +X-Loopers-Ldap: {{adminLdap}} + +{ + "status": "RETURNED" +} diff --git a/http/order-v1.http b/http/order-v1.http new file mode 100644 index 000000000..bffbb735c --- /dev/null +++ b/http/order-v1.http @@ -0,0 +1,62 @@ +### Order V1 API - 주문 API 테스트 + +### 환경 변수 +@baseUrl = http://localhost:8080 +@loginId = testuser +@password = Password123! + +### ===== 주문 생성 ===== + +### 주문 생성 +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +{ + "addressId": 1, + "shippingMemo": "문 앞에 놓아주세요", + "items": [ + { + "productId": 1, + "productOptionId": 1, + "quantity": 2 + } + ] +} + +### ===== 주문 목록 조회 ===== + +### 주문 목록 조회 (기본: 3개월) +GET {{baseUrl}}/api/v1/orders +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +### 주문 목록 조회 (6개월) +GET {{baseUrl}}/api/v1/orders?period=SIX_MONTHS +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +### 주문 목록 조회 (1년) +GET {{baseUrl}}/api/v1/orders?period=ONE_YEAR +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +### 주문 목록 조회 (전체) +GET {{baseUrl}}/api/v1/orders?period=ALL +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +### ===== 주문 상세 조회 ===== + +### 주문 상세 조회 +GET {{baseUrl}}/api/v1/orders/1 +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} + +### ===== 주문 취소 ===== + +### 주문 취소 +PATCH {{baseUrl}}/api/v1/orders/1/cancel +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{password}} From 37b3e3385f47d51d64b26418edab17761b8c05c9 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Feb 2026 02:10:19 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20Like=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Like Entity, Repository, Service 계층 구현 - LikeFacade 유즈케이스 오케스트레이션 - 좋아요 토글 API (추가/취소) - TargetType (PRODUCT) 지원 - 상품 좋아요 수 실시간 연동 - 통합 테스트 및 E2E 테스트 작성 Co-Authored-By: Claude Opus 4.5 --- .../loopers/application/like/LikeFacade.java | 55 ++++ .../loopers/application/like/LikeInfo.java | 7 + .../java/com/loopers/domain/like/Like.java | 29 ++ .../loopers/domain/like/LikeRepository.java | 12 + .../com/loopers/domain/like/LikeService.java | 41 +++ .../com/loopers/domain/like/TargetType.java | 6 + .../infrastructure/like/LikeEntity.java | 75 +++++ .../like/LikeJpaRepository.java | 11 + .../like/LikeRepositoryImpl.java | 35 ++ .../interfaces/api/like/LikeV1ApiSpec.java | 30 ++ .../interfaces/api/like/LikeV1Controller.java | 41 +++ .../interfaces/api/like/LikeV1Dto.java | 15 + .../application/like/LikeFacadeTest.java | 244 ++++++++++++++ .../loopers/domain/like/LikeServiceTest.java | 166 ++++++++++ .../interfaces/api/like/LikeV1ApiE2ETest.java | 300 ++++++++++++++++++ http/like-v1.http | 23 ++ 16 files changed, 1090 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/LikeInfo.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/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/TargetType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.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/LikeV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java create mode 100644 http/like-v1.http 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..1b8904f52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,55 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.like.TargetType; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class LikeFacade { + + private final LikeService likeService; + private final MemberService memberService; + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public LikeInfo toggleProductLike(String loginId, String password, Long productId) { + Member member = memberService.authenticate(loginId, password); + productService.getActiveProduct(productId); + + boolean liked = likeService.toggleLike(member.getId(), productId, TargetType.PRODUCT); + + Long likeCount; + if (liked) { + likeCount = productService.increaseLikeCount(productId); + } else { + likeCount = productService.decreaseLikeCount(productId); + } + + return new LikeInfo(liked, likeCount); + } + + @Transactional + public LikeInfo toggleBrandLike(String loginId, String password, Long brandId) { + Member member = memberService.authenticate(loginId, password); + brandService.getActiveBrand(brandId); + + boolean liked = likeService.toggleLike(member.getId(), brandId, TargetType.BRAND); + + Long likeCount; + if (liked) { + likeCount = brandService.increaseLikeCount(brandId); + } else { + likeCount = brandService.decreaseLikeCount(brandId); + } + + return new LikeInfo(liked, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..2e6185e46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,7 @@ +package com.loopers.application.like; + +public record LikeInfo( + boolean liked, + Long likeCount +) { +} 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..bcdf77a32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class Like { + + private Long id; + private Long memberId; + private Long targetId; + private TargetType targetType; + private LocalDateTime createdAt; + + public Like(Long memberId, Long targetId, TargetType targetType) { + this.memberId = memberId; + this.targetId = targetId; + this.targetType = targetType; + } + + public Like(Long id, Long memberId, Long targetId, TargetType targetType, LocalDateTime createdAt) { + this.id = id; + this.memberId = memberId; + this.targetId = targetId; + this.targetType = targetType; + this.createdAt = createdAt; + } +} 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..915297192 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType); + + Like save(Like like); + + void delete(Like like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..013eb2833 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,41 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + + @Transactional(propagation = Propagation.REQUIRED) + public boolean toggleLike(Long memberId, Long targetId, TargetType targetType) { + return likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType) + .map(existingLike -> { + likeRepository.delete(existingLike); + return false; + }) + .orElseGet(() -> { + try { + Like like = new Like(memberId, targetId, targetType); + likeRepository.save(like); + return true; + } catch (DataIntegrityViolationException e) { + // 동시 요청으로 이미 좋아요가 생성된 경우 → 삭제로 토글 처리 + likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType) + .ifPresent(likeRepository::delete); + return false; + } + }); + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRED) + public boolean existsLike(Long memberId, Long targetId, TargetType targetType) { + return likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType) + .isPresent(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/TargetType.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/TargetType.java new file mode 100644 index 000000000..3d903f678 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/TargetType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.like; + +public enum TargetType { + PRODUCT, + BRAND +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java new file mode 100644 index 000000000..526fb7c15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.TargetType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(name = "uk_likes", columnNames = {"member_id", "target_id", "target_type"}) + }, + indexes = { + @Index(name = "idx_likes_target", columnList = "target_id, target_type") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LikeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private TargetType targetType; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static LikeEntity from(Like like) { + LikeEntity entity = new LikeEntity(); + entity.memberId = like.getMemberId(); + entity.targetId = like.getTargetId(); + entity.targetType = like.getTargetType(); + return entity; + } + + public Like toDomain() { + return new Like( + id, + memberId, + targetId, + targetType, + createdAt != null ? createdAt.toLocalDateTime() : null + ); + } +} 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..d40c1ba83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.TargetType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType); +} 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..b00f0128c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType) { + return likeJpaRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType) + .map(LikeEntity::toDomain); + } + + @Override + public Like save(Like like) { + LikeEntity entity = LikeEntity.from(like); + return likeJpaRepository.save(entity).toDomain(); + } + + @Override + public void delete(Like like) { + likeJpaRepository.findByMemberIdAndTargetIdAndTargetType( + like.getMemberId(), like.getTargetId(), like.getTargetType()) + .ifPresent(likeJpaRepository::delete); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..7653390c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation( + summary = "상품 좋아요 토글", + description = "상품에 좋아요를 추가하거나 취소합니다. 좋아요가 없으면 추가하고, 있으면 취소합니다." + ) + ApiResponse toggleProductLike( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "상품 ID", required = true) Long productId + ); + + @Operation( + summary = "브랜드 좋아요 토글", + description = "브랜드에 좋아요를 추가하거나 취소합니다. 좋아요가 없으면 추가하고, 있으면 취소합니다." + ) + ApiResponse toggleBrandLike( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + @Parameter(description = "브랜드 ID", required = true) Long brandId + ); +} 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..dc97cbd22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/products/{productId}/like") + @Override + public ApiResponse toggleProductLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + LikeInfo info = likeFacade.toggleProductLike(loginId, password, productId); + return ApiResponse.success(LikeV1Dto.LikeResponse.from(info)); + } + + @PostMapping("/brands/{brandId}/like") + @Override + public ApiResponse toggleBrandLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long brandId + ) { + LikeInfo info = likeFacade.toggleBrandLike(loginId, password, brandId); + return ApiResponse.success(LikeV1Dto.LikeResponse.from(info)); + } +} 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..2cb233573 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; + +public class LikeV1Dto { + + public record LikeResponse( + boolean liked, + Long likeCount + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse(info.liked(), info.likeCount()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..7d23d0995 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,244 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.like.TargetType; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@DisplayName("LikeFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeFacadeTest { + + @InjectMocks + private LikeFacade likeFacade; + + @Mock + private LikeService likeService; + + @Mock + private MemberService memberService; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Nested + @DisplayName("toggleProductLike - 상품 좋아요 토글") + class ToggleProductLike { + + @Test + @DisplayName("인증 실패 시 UNAUTHORIZED 예외가 발생한다") + void throwsUnauthorized_whenAuthenticationFails() { + // Arrange + String loginId = "user1"; + String password = "wrongPassword"; + Long productId = 100L; + + given(memberService.authenticate(loginId, password)) + .willThrow(new CoreException(ErrorType.UNAUTHORIZED)); + + // Act & Assert + assertThatThrownBy(() -> likeFacade.toggleProductLike(loginId, password, productId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @Test + @DisplayName("상품이 존재하지 않으면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenProductDoesNotExist() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long productId = 999L; + Member member = createMember(1L, loginId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(productService.getActiveProduct(productId)) + .willThrow(new CoreException(ErrorType.NOT_FOUND)); + + // Act & Assert + assertThatThrownBy(() -> likeFacade.toggleProductLike(loginId, password, productId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("좋아요 생성 시 liked=true와 증가된 likeCount를 반환한다") + void returnsLikedTrueAndIncreasedCount_whenLikeCreated() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long productId = 100L; + Long memberId = 1L; + Member member = createMember(memberId, loginId); + Product product = createProduct(productId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(productService.getActiveProduct(productId)).willReturn(product); + given(likeService.toggleLike(memberId, productId, TargetType.PRODUCT)).willReturn(true); + given(productService.increaseLikeCount(productId)).willReturn(1L); + + // Act + LikeInfo result = likeFacade.toggleProductLike(loginId, password, productId); + + // Assert + assertThat(result.liked()).isTrue(); + assertThat(result.likeCount()).isEqualTo(1L); + verify(productService).increaseLikeCount(productId); + } + + @Test + @DisplayName("좋아요 삭제 시 liked=false와 감소된 likeCount를 반환한다") + void returnsLikedFalseAndDecreasedCount_whenLikeDeleted() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long productId = 100L; + Long memberId = 1L; + Member member = createMember(memberId, loginId); + Product product = createProduct(productId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(productService.getActiveProduct(productId)).willReturn(product); + given(likeService.toggleLike(memberId, productId, TargetType.PRODUCT)).willReturn(false); + given(productService.decreaseLikeCount(productId)).willReturn(0L); + + // Act + LikeInfo result = likeFacade.toggleProductLike(loginId, password, productId); + + // Assert + assertThat(result.liked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + verify(productService).decreaseLikeCount(productId); + } + } + + @Nested + @DisplayName("toggleBrandLike - 브랜드 좋아요 토글") + class ToggleBrandLike { + + @Test + @DisplayName("인증 실패 시 UNAUTHORIZED 예외가 발생한다") + void throwsUnauthorized_whenAuthenticationFails() { + // Arrange + String loginId = "user1"; + String password = "wrongPassword"; + Long brandId = 50L; + + given(memberService.authenticate(loginId, password)) + .willThrow(new CoreException(ErrorType.UNAUTHORIZED)); + + // Act & Assert + assertThatThrownBy(() -> likeFacade.toggleBrandLike(loginId, password, brandId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @Test + @DisplayName("브랜드가 존재하지 않으면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenBrandDoesNotExist() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long brandId = 999L; + Member member = createMember(1L, loginId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(brandService.getActiveBrand(brandId)) + .willThrow(new CoreException(ErrorType.NOT_FOUND)); + + // Act & Assert + assertThatThrownBy(() -> likeFacade.toggleBrandLike(loginId, password, brandId)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + + @Test + @DisplayName("좋아요 생성 시 liked=true와 증가된 likeCount를 반환한다") + void returnsLikedTrueAndIncreasedCount_whenLikeCreated() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long brandId = 50L; + Long memberId = 1L; + Member member = createMember(memberId, loginId); + Brand brand = createBrand(brandId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(brandService.getActiveBrand(brandId)).willReturn(brand); + given(likeService.toggleLike(memberId, brandId, TargetType.BRAND)).willReturn(true); + given(brandService.increaseLikeCount(brandId)).willReturn(1L); + + // Act + LikeInfo result = likeFacade.toggleBrandLike(loginId, password, brandId); + + // Assert + assertThat(result.liked()).isTrue(); + assertThat(result.likeCount()).isEqualTo(1L); + verify(brandService).increaseLikeCount(brandId); + } + + @Test + @DisplayName("좋아요 삭제 시 liked=false와 감소된 likeCount를 반환한다") + void returnsLikedFalseAndDecreasedCount_whenLikeDeleted() { + // Arrange + String loginId = "user1"; + String password = "password123"; + Long brandId = 50L; + Long memberId = 1L; + Member member = createMember(memberId, loginId); + Brand brand = createBrand(brandId); + + given(memberService.authenticate(loginId, password)).willReturn(member); + given(brandService.getActiveBrand(brandId)).willReturn(brand); + given(likeService.toggleLike(memberId, brandId, TargetType.BRAND)).willReturn(false); + given(brandService.decreaseLikeCount(brandId)).willReturn(0L); + + // Act + LikeInfo result = likeFacade.toggleBrandLike(loginId, password, brandId); + + // Assert + assertThat(result.liked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + verify(brandService).decreaseLikeCount(brandId); + } + } + + private Member createMember(Long id, String loginId) { + return new Member(id, loginId, "encodedPassword", "Test User", + LocalDate.of(1990, 1, 1), "test@example.com"); + } + + private Product createProduct(Long id) { + return new Product(id, "Test Product", "20240101-00001", 1L, 1L, 10000L, + com.loopers.domain.product.ProductStatus.SALE, null, null, 0L, + null, null, null); + } + + private Brand createBrand(Long id) { + return new Brand(id, "Test Brand", "Description", "https://example.com/logo.png", 0L, + null, null, null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..2a17de059 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,166 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("LikeService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @Nested + @DisplayName("toggleLike - 좋아요 토글") + class ToggleLike { + + @Test + @DisplayName("좋아요가 없으면 생성하고 true를 반환한다") + void returnsTrue_whenLikeDoesNotExist() { + // Arrange + Long memberId = 1L; + Long targetId = 100L; + TargetType targetType = TargetType.PRODUCT; + + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.empty()); + given(likeRepository.save(any(Like.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // Act + boolean result = likeService.toggleLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isTrue(); + verify(likeRepository).save(any(Like.class)); + verify(likeRepository, never()).delete(any(Like.class)); + } + + @Test + @DisplayName("좋아요가 있으면 삭제하고 false를 반환한다") + void returnsFalse_whenLikeExists() { + // Arrange + Long memberId = 1L; + Long targetId = 100L; + TargetType targetType = TargetType.PRODUCT; + Like existingLike = new Like(1L, memberId, targetId, targetType, null); + + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.of(existingLike)); + + // Act + boolean result = likeService.toggleLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isFalse(); + verify(likeRepository).delete(existingLike); + verify(likeRepository, never()).save(any(Like.class)); + } + + @Test + @DisplayName("BRAND 타입에서도 정상 동작한다") + void worksWithBrandType() { + // Arrange + Long memberId = 1L; + Long targetId = 50L; + TargetType targetType = TargetType.BRAND; + + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.empty()); + given(likeRepository.save(any(Like.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // Act + boolean result = likeService.toggleLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isTrue(); + } + + @Test + @DisplayName("save 시 DataIntegrityViolationException 발생하면 삭제로 토글 처리한다") + void whenDataIntegrityViolationException_thenDeleteAndReturnFalse() { + // Arrange + Long memberId = 1L; + Long targetId = 100L; + TargetType targetType = TargetType.PRODUCT; + Like existingLike = new Like(1L, memberId, targetId, targetType, null); + + // 첫 번째 조회: 없음 (save 시도) + // 두 번째 조회: 있음 (동시 요청으로 다른 요청이 이미 생성함) + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.empty()) + .willReturn(Optional.of(existingLike)); + + willThrow(new DataIntegrityViolationException("Duplicate entry")) + .given(likeRepository).save(any(Like.class)); + + // Act + boolean result = likeService.toggleLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isFalse(); + verify(likeRepository).delete(existingLike); + } + } + + @Nested + @DisplayName("existsLike - 좋아요 존재 여부 확인") + class ExistsLike { + + @Test + @DisplayName("좋아요가 존재하면 true를 반환한다") + void returnsTrue_whenLikeExists() { + // Arrange + Long memberId = 1L; + Long targetId = 100L; + TargetType targetType = TargetType.PRODUCT; + Like existingLike = new Like(1L, memberId, targetId, targetType, null); + + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.of(existingLike)); + + // Act + boolean result = likeService.existsLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isTrue(); + } + + @Test + @DisplayName("좋아요가 존재하지 않으면 false를 반환한다") + void returnsFalse_whenLikeDoesNotExist() { + // Arrange + Long memberId = 1L; + Long targetId = 100L; + TargetType targetType = TargetType.PRODUCT; + + given(likeRepository.findByMemberIdAndTargetIdAndTargetType(memberId, targetId, targetType)) + .willReturn(Optional.empty()); + + // Act + boolean result = likeService.existsLike(memberId, targetId, targetType); + + // Assert + assertThat(result).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..388fd3316 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -0,0 +1,300 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +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) +class LikeV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + ProductRepository productRepository, + BrandRepository brandRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.productRepository = productRepository; + this.brandRepository = brandRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/products/{productId}/like") + @Nested + class ToggleProductLike { + + private Member member; + private Brand brand; + private Product product; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + brand = saveBrand("Nike"); + product = saveProduct("Test Product", brand.getId()); + } + + @Test + @DisplayName("좋아요 추가 시 200 OK와 liked=true, likeCount=1을 반환한다") + void returnsOkAndLikedTrue_whenLikeAdded() { + // act + ResponseEntity> response = toggleProductLike( + product.getId(), member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isTrue(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 토글 시 두 번째 호출에서 liked=false, likeCount=0을 반환한다") + void returnsLikedFalse_whenToggledTwice() { + // arrange - 첫 번째 좋아요 + toggleProductLike(product.getId(), member.getLoginId(), "Password123!"); + + // act - 두 번째 좋아요 (취소) + ResponseEntity> response = toggleProductLike( + product.getId(), member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isFalse(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("인증 실패 시 401 Unauthorized를 반환한다") + void returnsUnauthorized_whenAuthenticationFails() { + // act + ResponseEntity> response = toggleProductLikeWithError( + product.getId(), member.getLoginId(), "WrongPassword!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 시 404 Not Found를 반환한다") + void returnsNotFound_whenProductNotExists() { + // act + ResponseEntity> response = toggleProductLikeWithError( + 999L, member.getLoginId(), "Password123!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private ResponseEntity> toggleProductLike( + Long productId, String loginId, String password + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + return testRestTemplate.exchange( + "/api/v1/products/" + productId + "/like", + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + } + + private ResponseEntity> toggleProductLikeWithError( + Long productId, String loginId, String password + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + return testRestTemplate.exchange( + "/api/v1/products/" + productId + "/like", + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + } + } + + @DisplayName("POST /api/v1/brands/{brandId}/like") + @Nested + class ToggleBrandLike { + + private Member member; + private Brand brand; + + @BeforeEach + void setUp() { + member = saveMember("user1", "Password123!"); + brand = saveBrand("Nike"); + } + + @Test + @DisplayName("좋아요 추가 시 200 OK와 liked=true, likeCount=1을 반환한다") + void returnsOkAndLikedTrue_whenLikeAdded() { + // act + ResponseEntity> response = toggleBrandLike( + brand.getId(), member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isTrue(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 토글 시 두 번째 호출에서 liked=false, likeCount=0을 반환한다") + void returnsLikedFalse_whenToggledTwice() { + // arrange - 첫 번째 좋아요 + toggleBrandLike(brand.getId(), member.getLoginId(), "Password123!"); + + // act - 두 번째 좋아요 (취소) + ResponseEntity> response = toggleBrandLike( + brand.getId(), member.getLoginId(), "Password123!" + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isFalse(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("인증 실패 시 401 Unauthorized를 반환한다") + void returnsUnauthorized_whenAuthenticationFails() { + // act + ResponseEntity> response = toggleBrandLikeWithError( + brand.getId(), member.getLoginId(), "WrongPassword!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("존재하지 않는 브랜드에 좋아요 시 404 Not Found를 반환한다") + void returnsNotFound_whenBrandNotExists() { + // act + ResponseEntity> response = toggleBrandLikeWithError( + 999L, member.getLoginId(), "Password123!" + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private ResponseEntity> toggleBrandLike( + Long brandId, String loginId, String password + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + return testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/like", + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + } + + private ResponseEntity> toggleBrandLikeWithError( + Long brandId, String loginId, String password + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + return testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/like", + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + } + } + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "Test User", + LocalDate.of(1990, 1, 1), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private Brand saveBrand(String name) { + Brand brand = new Brand(name, "Description", "https://example.com/logo.png"); + return brandRepository.save(brand); + } + + private Product saveProduct(String name, Long brandId) { + Product product = new Product(name, brandId, 1L, 10000L); + return productRepository.save(product); + } +} diff --git a/http/like-v1.http b/http/like-v1.http new file mode 100644 index 000000000..26e8b659e --- /dev/null +++ b/http/like-v1.http @@ -0,0 +1,23 @@ +### 상품 좋아요 토글 +POST {{host}}/api/v1/products/1/like +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +### 상품 좋아요 토글 (두 번째 호출 - 취소) +POST {{host}}/api/v1/products/1/like +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +### 브랜드 좋아요 토글 +POST {{host}}/api/v1/brands/1/like +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123 + +### 브랜드 좋아요 토글 (두 번째 호출 - 취소) +POST {{host}}/api/v1/brands/1/like +Content-Type: application/json +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password123