From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) 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/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 66808902639ad55491f72c1c6a9a9e35cea55560 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Feb 2026 14:21:22 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[docs]=20:=20=EC=84=A4=EA=B3=84=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=ED=81=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=82=AC=20md=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/requirement-analysis/SKILL.md | 77 ++ .docs/design/01-requirements.md | 354 +++++ .docs/design/02-sequence-diagrams.md | 1211 ++++++++++++++++++ .docs/design/03-class-diagrams.md | 370 ++++++ .docs/design/04-erd.md | 337 +++++ 5 files changed, 2349 insertions(+) create mode 100644 .claude/skills/requirement-analysis/SKILL.md create mode 100644 .docs/design/01-requirements.md create mode 100644 .docs/design/02-sequence-diagrams.md create mode 100644 .docs/design/03-class-diagrams.md create mode 100644 .docs/design/04-erd.md diff --git a/.claude/skills/requirement-analysis/SKILL.md b/.claude/skills/requirement-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/requirement-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..431df28f5 --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,354 @@ +# 이커머스 요구사항 정의서 + +## 1. 목적 및 범위 + +### 1.1 목적 + +온라인 커머스 플랫폼을 구축한다. +고객은 브랜드별 상품을 탐색하고, 관심 상품에 좋아요를 표시하며, 주문을 통해 상품을 구매할 수 있다. +관리자는 브랜드와 상품을 등록/관리하고, 주문 현황을 조회할 수 있다. + +### 1.2 시스템 사용자 + +| 사용자 유형 | 설명 | +|-------------------|------| +| **비회원 (Visitor)** | 회원가입 전 상태의 사용자. 공개 정보 조회만 가능하다 | +| **회원 (User)** | 회원가입을 완료한 사용자. 좋아요, 주문 등 인증이 필요한 기능을 사용한다 | +| **관리자 (Admin)** | 내부 운영자. 브랜드/상품 관리 및 주문 조회를 수행한다 | + +### 1.3 범위 + +본 문서는 아래 도메인의 기능적 요구사항을 정의한다. + +- **브랜드**: 조회(고객), 등록/수정/삭제(관리자) +- **상품**: 조회(고객), 등록/수정/삭제(관리자) +- **좋아요**: 상품에 대한 좋아요 등록/취소/목록 조회 +- **장바구니**: 상품 담기/수량 변경/제거/조회 +- **주문**: 주문 생성, 주문 내역 조회 + +> 회원 도메인(가입, 인증, 내 정보 관리)은 이미 구현 완료되었으므로 본 문서의 범위에서 제외한다. + +### 1.4 유비쿼터스 언어 + +본 프로젝트에서 사용하는 도메인 용어를 아래와 같이 정의한다. +모든 문서(요구사항, ERD, API 명세), 코드(클래스명, 변수명), 구두 커뮤니케이션에서 동일한 용어를 사용한다. + +#### 사용자 + +| 한국어 | 영문 | 정의 | +|--------|-------|------| +| 회원 | User | 회원가입을 완료하여 인증된 상태로 서비스를 이용하는 사용자 | +| 관리자 | Admin | 내부 운영 권한을 가진 사용자. 브랜드/상품 관리, 주문 조회를 수행한다 | + +#### 브랜드 + +| 한국어 | 영문 | 정의 | +|--------|------|------| +| 브랜드 | Brand | 상품을 제공하는 주체. 하나의 브랜드는 여러 상품을 가질 수 있다 | +| 브랜드명 | Brand Name | 브랜드를 식별하는 이름 | + +#### 상품 + +| 한국어 | 영문 | 정의 | +|--------|------|------| +| 상품 | Product | 고객이 구매할 수 있는 판매 단위 | +| 상품명 | Product Name | 상품을 식별하는 이름 | +| 가격 | Price | 상품 1개의 판매 금액 | +| 재고 | Stock | 현재 판매 가능한 상품의 수량 | + +#### 좋아요 + +| 한국어 | 영문 | 정의 | +|--------|------|------| +| 좋아요 | Like | 회원이 특정 상품에 대해 관심을 표시하는 행위. 회원당 상품당 하나만 존재한다 | + +#### 장바구니 + +| 한국어 | 영문 | 정의 | +|--------|------|------| +| 장바구니 | Cart | 회원이 구매를 고려하는 상품을 임시로 모아두는 공간. 회원당 하나 존재한다 | +| 장바구니 항목 | Cart Item | 장바구니에 담긴 개별 상품과 그 수량 | + +#### 주문 + +| 한국어 | 영문 | 정의 | +|--------|------|------| +| 주문 | Order | 회원이 하나 이상의 상품을 구매하기 위해 생성하는 거래 단위 | +| 주문 항목 | Order Item | 주문에 포함된 개별 상품과 그 수량. 주문 시점의 상품 정보를 스냅샷으로 보존한다 | +| 수량 | Quantity | 주문 항목에서 해당 상품을 구매하는 개수 | +| 스냅샷 | Snapshot | 주문 시점의 상품 정보(상품명, 가격, 브랜드 등) 사본. 원본이 변경되어도 주문 기록은 유지된다 | + +> **용어 사용 원칙**: "아이템"이 아닌 "상품", "취소"가 아닌 "좋아요 취소"처럼, 위 표에 정의된 용어만을 사용한다. 새로운 용어가 필요한 경우 본 표에 먼저 추가한 후 사용한다. + +--- + +## 2. 유저 시나리오 + +### 2.1 브랜드 (Brand) + +#### US-B01: 브랜드 정보 조회 (고객) + +> 고객(비회원/회원)은 특정 브랜드의 정보를 조회할 수 있다. + +#### US-B02: 브랜드 목록 조회 (관리자) + +> 관리자는 등록된 브랜드 목록을 조회할 수 있다. + +- 관리에 필요한 상세 정보를 확인할 수 있다. + +#### US-B03: 브랜드 상세 조회 (관리자) + +> 관리자는 특정 브랜드의 상세 정보를 조회할 수 있다. + +#### US-B04: 브랜드 등록 (관리자) + +> 관리자는 새로운 브랜드를 등록할 수 있다. + +#### US-B05: 브랜드 정보 수정 (관리자) + +> 관리자는 기존 브랜드의 정보를 수정할 수 있다. + +#### US-B06: 브랜드 삭제 (관리자) + +> 관리자는 브랜드를 삭제할 수 있다. + +- 브랜드 삭제 시, 해당 브랜드에 속한 모든 상품도 함께 삭제된다. + +--- + +### 2.2 상품 (Product) + +#### US-P01: 상품 목록 조회 (고객) + +> 고객(비회원/회원)은 상품 목록을 탐색할 수 있다. + +- 특정 브랜드의 상품만 필터링하여 볼 수 있다. +- 정렬 기준을 선택할 수 있다 (최신순은 필수, 가격순/좋아요순은 선택). +- 페이지 단위로 조회한다. + +#### US-P02: 상품 상세 조회 (고객) + +> 고객(비회원/회원)은 특정 상품의 상세 정보를 조회할 수 있다. + +#### US-P03: 상품 목록 조회 (관리자) + +> 관리자는 등록된 상품 목록을 조회할 수 있다. + +- 브랜드별로 필터링하여 조회할 수 있다. +- 관리에 필요한 상세 정보를 확인할 수 있다. + +#### US-P04: 상품 상세 조회 (관리자) + +> 관리자는 특정 상품의 상세 정보를 조회할 수 있다. + +#### US-P05: 상품 등록 (관리자) + +> 관리자는 새로운 상품을 등록할 수 있다. + +- 상품은 반드시 이미 등록된 브랜드에 속해야 한다. + +#### US-P06: 상품 정보 수정 (관리자) + +> 관리자는 기존 상품의 정보를 수정할 수 있다. + +- 단, 상품의 소속 브랜드는 변경할 수 없다. + +#### US-P07: 상품 삭제 (관리자) + +> 관리자는 상품을 삭제할 수 있다. + +--- + +### 2.3 좋아요 (Like) + +#### US-L01: 상품 좋아요 등록 + +> 회원은 관심 있는 상품에 좋아요를 등록할 수 있다. + +#### US-L02: 상품 좋아요 취소 + +> 회원은 이전에 등록한 좋아요가 있다면, 한 번 더 클릭하여 좋아요를 취소할 수 있다. + +#### US-L03: 좋아요한 상품 목록 조회 + +> 회원은 자신이 좋아요를 등록한 상품 목록을 조회할 수 있다. + +--- + +### 2.4 장바구니 (Cart) + +#### US-C01: 장바구니에 상품 담기 + +> 회원은 구매를 고려하는 상품을 장바구니에 담을 수 있다. + +- 담을 상품과 수량을 지정한다. +- 이미 장바구니에 있는 상품을 다시 담으면, 수량이 누적된다. + +#### US-C02: 장바구니 조회 + +> 회원은 자신의 장바구니에 담긴 상품 목록을 조회할 수 있다. + +#### US-C03: 장바구니 상품 수량 변경 + +> 회원은 장바구니에 담긴 상품의 수량을 변경할 수 있다. + +#### US-C04: 장바구니 상품 제거 + +> 회원은 장바구니에서 특정 상품을 제거할 수 있다. + +--- + +### 2.5 주문 (Order) + +#### US-O01: 주문 생성 + +> 회원은 하나 이상의 상품을 선택하고 수량을 지정하여 주문할 수 있다. + +- 주문 시점의 상품 정보가 주문에 스냅샷으로 보존된다. +- 주문이 완료되면, 해당 수량만큼 상품 재고가 차감된다. + +#### US-O02: 주문 목록 조회 (회원) + +> 회원은 자신의 주문 내역을 기간을 지정하여 조회할 수 있다. + +- 조회 시작일과 종료일을 지정한다. + +#### US-O03: 주문 상세 조회 (회원) + +> 회원은 자신의 특정 주문의 상세 내역을 조회할 수 있다. + +- 주문 당시의 브랜드,상품 정보를 확인할 수 있다. + +#### US-O04: 주문 목록 조회 (관리자) + +> 관리자는 전체 주문 목록을 페이지 단위로 조회할 수 있다. + +#### US-O05: 주문 상세 조회 (관리자) + +> 관리자는 특정 주문의 상세 내역을 조회할 수 있다. + +--- + +## 3. 비즈니스 규칙 + +### 3.1 인증 및 권한 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|------| +| BR-A01 | 회원 전용 기능은 인증된 사용자만 접근할 수 있다 | 좋아요, 주문 등 | +| BR-A02 | 관리자 전용 기능은 관리자 인증을 통과한 사용자만 접근할 수 있다 | 브랜드/상품 관리, 주문 관리 | +| BR-A03 | 공개 기능(상품 조회, 브랜드 조회)은 인증 없이 접근할 수 있다 | | + +### 3.2 브랜드 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|------| +| BR-B01 | 브랜드를 삭제하면, 해당 브랜드에 속한 모든 상품도 함께 삭제된다 | 연쇄 삭제 | +| BR-B02 | 고객에게 제공되는 브랜드 정보와 관리자에게 제공되는 브랜드 정보는 다를 수 있다 | 노출 범위 구분 | + +### 3.3 상품 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|-------------------------------------------------------| +| BR-P01 | 상품은 반드시 이미 등록된 브랜드에 속해야 한다 | 등록 시 검증 | +| BR-P02 | 상품의 소속 브랜드는 등록 이후 변경할 수 없다 | 불변 속성 | +| BR-P03 | 상품 목록은 최신순 정렬을 기본으로 제공한다 | 필수 정렬 | +| BR-P04 | 가격순, 좋아요순 정렬은 선택적으로 제공할 수 있다 | 선택 구현 | +| BR-P05 | 고객에게 제공되는 상품 정보와 관리자에게 제공되는 상품 정보는 다를 수 있다 | 노출 범위 구분 (예: 회원 및 비회원은 재고 여부만 확인, 관리자는 재고 수량까지 확인 가능) | + +### 3.4 좋아요 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|------| +| BR-L01 | 한 회원은 동일 상품에 좋아요를 한 번만 등록할 수 있다 | 중복 방지 | +| BR-L02 | 좋아요를 등록하지 않은 상품에 대해 취소할 수 없다 | | +| BR-L03 | 회원은 자신의 좋아요 목록만 조회할 수 있다 | | + +### 3.5 장바구니 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|------| +| BR-C01 | 회원은 하나의 장바구니를 가진다 | | +| BR-C02 | 이미 장바구니에 있는 상품을 다시 담으면, 기존 수량에 누적된다 | | +| BR-C03 | 장바구니 항목의 수량은 1 이상이어야 한다 | | +| BR-C04 | 회원은 자신의 장바구니만 조회/수정할 수 있다 | | + +### 3.6 주문 + +| 규칙 ID | 규칙 | 비고 | +|---------|------|------| +| BR-O01 | 주문은 하나 이상의 상품 항목을 포함해야 한다 | | +| BR-O02 | 각 상품 항목의 수량은 1 이상이어야 한다 | | +| BR-O03 | 주문 시점에 상품 재고가 충분해야 한다 | 재고 부족 시 주문 실패 | +| BR-O04 | 주문이 성공하면, 주문한 수량만큼 상품 재고가 차감된다 | | +| BR-O05 | 주문에는 당시 상품 정보가 스냅샷으로 보존된다 | 이후 상품 정보가 변경되어도 주문 기록은 유지 | +| BR-O06 | 회원은 자신의 주문만 조회할 수 있다 | | +| BR-O07 | 관리자는 전체 주문을 조회할 수 있다 | | +| BR-O08 | 주문 목록 조회 시, 기간(시작일~종료일)을 지정하여 필터링한다 | 회원 조회 시 | + +--- + +## 4. 예외 및 정책 + +### 4.1 공통 + +| 예외 상황 | 정책 | +|-----------|------| +| 인증되지 않은 사용자가 인증 필요 기능에 접근 | 접근을 거부하고, 인증 필요 사유를 안내한다 | +| 관리자 인증 없이 관리자 기능에 접근 | 접근을 거부한다 | + +### 4.2 브랜드 + +| 예외 상황 | 정책 | +|-----------|------| +| 존재하지 않는 브랜드 조회/수정/삭제 시도 | 요청을 거부하고, 존재하지 않음을 안내한다 | + +### 4.3 상품 + +| 예외 상황 | 정책 | +|-----------|------| +| 존재하지 않는 브랜드에 상품 등록 시도 | 등록을 거부하고, 브랜드가 존재하지 않음을 안내한다 | +| 상품의 소속 브랜드 변경 시도 | 수정을 거부한다 | +| 존재하지 않는 상품 조회/수정/삭제 시도 | 요청을 거부하고, 존재하지 않음을 안내한다 | + +### 4.4 좋아요 + +| 예외 상황 | 정책 | +|-----------|------| +| 이미 좋아요한 상품에 다시 좋아요 시도 | 등록을 거부하고, 이미 좋아요 상태임을 안내한다 | +| 좋아요하지 않은 상품에 좋아요 취소 시도 | 취소를 거부하고, 좋아요 상태가 아님을 안내한다 | +| 존재하지 않는 상품에 좋아요 시도 | 등록을 거부한다 | + +### 4.5 장바구니 + +| 예외 상황 | 정책 | +|-----------|------| +| 존재하지 않는 상품을 장바구니에 담으려는 경우 | 담기를 거부한다 | +| 장바구니에 없는 상품의 수량을 변경하려는 경우 | 변경을 거부한다 | +| 수량을 0 이하로 변경하려는 경우 | 변경을 거부한다 | + +### 4.6 주문 + +| 예외 상황 | 정책 | +|-----------|------| +| 주문 상품의 재고가 부족한 경우 | 주문을 거부하고, 재고 부족 사유를 안내한다 | +| 존재하지 않는 상품을 주문에 포함한 경우 | 주문을 거부한다 | +| 주문 항목이 비어있는 경우 | 주문을 거부한다 | +| 수량이 0 이하인 항목이 포함된 경우 | 주문을 거부한다 | +| 다른 회원의 주문을 조회하려는 경우 | 접근을 거부한다 | + +--- + +## 5. 이번 범위에서 제외하는 것들 + +| 항목 | 사유 | +|------|------| +| **회원 도메인** | 이미 구현 완료 (가입, 인증, 내 정보 관리, 비밀번호 변경) | +| **결제** | 추후 별도 단계에서 개발 예정 | +| **주문 취소/환불** | 결제 기능과 연계하여 추후 개발 | +| **주문 상태 관리** (배송 중, 배송 완료 등) | 현재 범위에서는 주문 생성과 조회만 다룬다 | +| **상품 이미지 관리** | 파일 업로드/스토리지는 현재 범위에 포함하지 않는다 | +| **상품 카테고리** | 현재는 브랜드 단위로만 상품을 분류한다 | +| **검색 (키워드 기반 상품 검색)** | 현재는 브랜드 필터링과 정렬만 제공한다 | +| **회원 탈퇴** | 현재 범위에 포함하지 않는다 | +| **관리자 등록/관리** | 관리자 계정 관리는 현재 범위에 포함하지 않는다 | diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..c17917742 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,1211 @@ +# 시퀀스 다이어그램 + +## 공통 사항 + +### 인증/인가 + +- 회원 전용 기능(좋아요, 장바구니, 주문)과 관리자 전용 기능(브랜드/상품 관리)은 AuthInterceptor에서 인증을 선처리한다. +- 인증 실패 시 Controller에 도달하기 전에 요청이 거부된다. +- 아래 다이어그램은 **인증이 통과된 이후의 흐름**만 표현한다. + +### 계층 간 데이터 흐름 + +- 각 계층 경계에서 DTO가 변환된다 (Request DTO → Command, Entity → Info → Response DTO). +- DTO 변환은 각 컴포넌트의 내부 책임이므로 시퀀스 다이어그램에 표현하지 않는다. + +--- + +## 2.1 브랜드 (Brand) + +### US-B01: 브랜드 정보 조회 (고객) + +#### 검증 목적 + +고객의 브랜드 조회 요청이 각 계층을 통과하는 순서와, "브랜드가 없다"를 예외로 판단하는 책임이 어느 계층에 있는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 고객 + participant Controller as BrandV1Controller + participant Facade as BrandFacade + participant Service as BrandService + participant Repository as BrandRepository + + 고객->>Controller: 브랜드 정보 조회 요청 + Controller->>Facade: 브랜드 조회 위임 + Facade->>Service: 브랜드 조회 + Service->>Repository: 브랜드 존재 여부 확인 + + alt 브랜드가 존재하는 경우 + Repository-->>Service: 브랜드 정보 + Service-->>Facade: 브랜드 정보 + Facade-->>Controller: 브랜드 정보 + Controller-->>고객: 브랜드 정보 응답 + else 브랜드가 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>고객: 존재하지 않음 안내 + end +``` + +#### 봐야 할 포인트 + +1. **예외 발생 위치**: Repository는 "없음"만 반환하고, 비즈니스 예외로 변환하는 책임은 Service에 있다. +2. **Facade의 역할**: 단일 도메인 조회이므로 Facade는 Service 호출을 위임만 한다. + +--- + +### US-B02: 브랜드 목록 조회 (관리자) + +#### 검증 목적 + +목록 조회는 결과가 비어있어도 정상 응답이므로 예외 분기가 없다. 단건 조회와의 차이를 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as BrandAdminV1Controller + participant Facade as BrandAdminFacade + participant Service as BrandService + participant Repository as BrandRepository + + 관리자->>Controller: 브랜드 목록 조회 요청 + Controller->>Facade: 브랜드 목록 조회 위임 + Facade->>Service: 브랜드 목록 조회 + Service->>Repository: 브랜드 목록 조회 + Repository-->>Service: 브랜드 목록 + Service-->>Facade: 브랜드 목록 + Facade-->>Controller: 브랜드 목록 + Controller-->>관리자: 브랜드 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **예외 분기 없음**: 결과가 비어있어도 빈 목록으로 정상 응답한다. +2. **BR-B02**: 관리자용 브랜드 정보는 고객용과 다를 수 있다. 차이는 Controller의 DTO 변환에서 결정된다. + +--- + +### US-B03: 브랜드 상세 조회 (관리자) + +#### 검증 목적 + +관리자 단건 조회가 고객 조회(US-B01)와 동일한 계층 흐름을 따르되, 응답 범위만 다른지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as BrandAdminV1Controller + participant Facade as BrandAdminFacade + participant Service as BrandService + participant Repository as BrandRepository + + 관리자->>Controller: 브랜드 상세 조회 요청 + Controller->>Facade: 브랜드 조회 위임 + Facade->>Service: 브랜드 조회 + Service->>Repository: 브랜드 존재 여부 확인 + + alt 브랜드가 존재하는 경우 + Repository-->>Service: 브랜드 정보 + Service-->>Facade: 브랜드 정보 + Facade-->>Controller: 브랜드 정보 + Controller-->>관리자: 브랜드 상세 정보 응답 + else 브랜드가 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end +``` + +#### 봐야 할 포인트 + +1. **US-B01과 동일한 흐름**: 차이는 액터(관리자)와 응답 DTO의 범위뿐이다. BR-B02에 따라 관리자용 정보가 더 상세할 수 있다. + +--- + +### US-B04: 브랜드 등록 (관리자) + +#### 검증 목적 + +브랜드 등록 시 브랜드명 중복 여부를 확인한 후 저장하는 흐름을 검증한다. 다른 도메인에 대한 의존은 없지만, 도메인 내부 유일성 제약이 존재한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as BrandAdminV1Controller + participant Facade as BrandAdminFacade + participant Service as BrandService + participant Repository as BrandRepository + + 관리자->>Controller: 브랜드 등록 요청 + Controller->>Facade: 브랜드 등록 위임 + Facade->>Service: 브랜드 등록 + Service->>Repository: 브랜드명 중복 여부 확인 + + alt 브랜드명 중복 시 + Repository-->>Service: 브랜드명 중복 + Service-->>Facade: 중복 예외 + Facade-->>Controller: 중복 예외 전파 + Controller-->>관리자: 409 Conflict + end + + Repository-->>Service: 중복되지 않음 + Service->>Repository: 브랜드 정보 저장 + Repository-->>Service: 저장된 브랜드 정보 + Service-->>Facade: 브랜드 정보 + Facade-->>Controller: 브랜드 정보 + Controller-->>관리자: 브랜드 등록 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **브랜드명 중복 검증**: Service가 저장 전에 동일 브랜드명의 존재 여부를 확인한다. 중복 시 CONFLICT(409)로 응답한다. +2. **early-return 패턴**: 중복 검증 실패 시 즉시 예외를 반환하고, 정상 흐름은 `alt` 블록 이후에 계속된다. + +--- + +### US-B05: 브랜드 정보 수정 (관리자) + +#### 검증 목적 + +브랜드 수정 시 "존재 여부 확인 → 브랜드명 중복 확인 → 수정" 순서를 확인한다. US-B04와 마찬가지로 브랜드명 유일성 제약이 적용된다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as BrandAdminV1Controller + participant Facade as BrandAdminFacade + participant Service as BrandService + participant Repository as BrandRepository + + 관리자->>Controller: 브랜드 수정 요청 + Controller->>Facade: 브랜드 수정 위임 + Facade->>Service: 브랜드 수정 + Service->>Repository: 브랜드 존재 여부 확인 + + alt 브랜드가 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end + + Repository-->>Service: 브랜드 정보 + Service->>Repository: 변경할 브랜드명 중복 여부 확인 + + alt 브랜드명 중복 시 + Repository-->>Service: 브랜드명 중복 + Service-->>Facade: 중복 예외 + Facade-->>Controller: 중복 예외 전파 + Controller-->>관리자: 409 Conflict + end + + Repository-->>Service: 중복되지 않음 + Service->>Repository: 브랜드 수정 + Repository-->>Service: 수정된 브랜드 정보 + Service-->>Facade: 브랜드 정보 + Facade-->>Controller: 브랜드 정보 + Controller-->>관리자: 브랜드 수정 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **이중 검증**: 존재 확인 → 브랜드명 중복 확인 → 수정의 3단계. US-B04(등록)의 단일 검증보다 한 단계가 추가된다. +2. **자기 자신 제외**: 수정 시 브랜드명 중복 확인은 자기 자신을 제외한 다른 브랜드와 비교해야 한다. 이 구분은 Repository 쿼리에서 처리된다. + +--- + +### US-B06: 브랜드 삭제 (관리자) + +#### 검증 목적 + +BR-B01(연쇄 삭제)의 책임이 어느 계층에 있는지 확인한다. 브랜드 삭제 → 상품 삭제 → 좋아요 삭제의 3단계 연쇄가 발생하며, Facade가 BrandService, ProductService, LikeService를 조율한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as BrandAdminV1Controller + participant Facade as BrandAdminFacade + participant BrandService + participant BrandRepository + participant LikeService + participant LikeRepository + participant CartService + participant CartRepository + participant ProductService + participant ProductRepository + + 관리자->>Controller: 브랜드 삭제 요청 + Controller->>Facade: 브랜드 삭제 위임 + Facade->>BrandService: 브랜드 존재 확인 + BrandService->>BrandRepository: 브랜드 존재 여부 확인 + + alt 브랜드가 존재하지 않는 경우 + BrandRepository-->>BrandService: 없음 + BrandService->>BrandService: 비즈니스 예외 발생 + BrandService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end + + BrandRepository-->>BrandService: 브랜드 정보 + BrandService-->>Facade: 브랜드 정보 + Facade->>LikeService: 해당 브랜드 상품 좋아요 전체 삭제 + LikeService->>LikeRepository: 좋아요 전체 삭제 (hard delete) + LikeRepository-->>LikeService: 삭제 완료 + LikeService-->>Facade: 삭제 완료 + Facade->>CartService: 해당 브랜드 상품 장바구니 항목 전체 삭제 + CartService->>CartRepository: 장바구니 항목 전체 삭제 (hard delete) + CartRepository-->>CartService: 삭제 완료 + CartService-->>Facade: 삭제 완료 + Facade->>ProductService: 해당 브랜드 상품 전체 삭제 + ProductService->>ProductRepository: 상품 전체 삭제 (soft delete) + ProductRepository-->>ProductService: 삭제 완료 + ProductService-->>Facade: 삭제 완료 + Facade->>BrandService: 브랜드 삭제 + BrandService->>BrandRepository: 브랜드 삭제 (soft delete) + BrandRepository-->>BrandService: 삭제 완료 + BrandService-->>Facade: 삭제 완료 + Facade-->>Controller: 삭제 완료 + Controller-->>관리자: 브랜드 삭제 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **Facade의 4-서비스 조율**: BrandService, LikeService, CartService, ProductService는 서로를 모른다. 도메인 간 삭제 순서를 Facade가 결정한다. +2. **삭제 순서와 정책**: 좋아요(hard delete) → 장바구니 항목(hard delete) → 상품(soft delete) → 브랜드(soft delete). 종속 데이터를 먼저 정리해야 상위 엔티티 삭제 후 고아 데이터가 남지 않는다. + +#### 잠재 리스크 + +- **트랜잭션 범위**: 좋아요 삭제, 장바구니 항목 삭제, 상품 삭제, 브랜드 삭제가 하나의 트랜잭션으로 묶여야 한다. 4개 서비스를 포함하므로 트랜잭션이 넓다. +- **Soft Delete 연쇄 정책**: 브랜드 복원 시 상품도 함께 복원해야 하는지, 복원된 상품의 좋아요와 장바구니 항목은 이미 hard delete되어 복원 불가능한 점을 어떻게 다룰지 정책 결정이 필요하다. + +--- + +## 2.2 상품 (Product) + +### US-P01: 상품 목록 조회 (고객) + +#### 검증 목적 + +상품 목록 조회에는 브랜드 필터링, 정렬(BR-P03, BR-P04), 페이징이 포함된다. 필터/정렬 조건의 처리 책임이 어느 계층에 있는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 고객 + participant Controller as ProductV1Controller + participant Facade as ProductFacade + participant Service as ProductService + participant Repository as ProductRepository + + 고객->>Controller: 상품 목록 조회 요청 (필터, 정렬, 페이징) + Controller->>Facade: 상품 목록 조회 위임 + Facade->>Service: 상품 목록 조회 + Service->>Repository: 조건부 목록 조회 + Repository-->>Service: 상품 목록 (페이징) + Service-->>Facade: 상품 목록 + Facade-->>Controller: 상품 목록 + Controller-->>고객: 상품 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **필터/정렬 책임**: 브랜드 필터링과 정렬 조건은 Repository 계층의 쿼리로 처리된다. Service는 조건을 전달만 한다. +2. **BR-P05**: 고객에게는 재고 여부(있음/없음)만 노출하고, 구체적 재고 수량은 숨긴다. DTO 변환에서 결정된다. + +--- + +### US-P02: 상품 상세 조회 (고객) + +#### 검증 목적 + +단건 조회의 정상/예외 분기를 확인한다. 브랜드 조회(US-B01)와 동일한 패턴을 따른다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 고객 + participant Controller as ProductV1Controller + participant Facade as ProductFacade + participant Service as ProductService + participant Repository as ProductRepository + + 고객->>Controller: 상품 상세 조회 요청 + Controller->>Facade: 상품 조회 위임 + Facade->>Service: 상품 조회 + Service->>Repository: 상품 존재 여부 확인 + + alt 상품이 존재하는 경우 + Repository-->>Service: 상품 정보 + Service-->>Facade: 상품 정보 + Facade-->>Controller: 상품 정보 + Controller-->>고객: 상품 상세 정보 응답 + else 상품이 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>고객: 존재하지 않음 안내 + end +``` + +#### 봐야 할 포인트 + +1. **US-B01과 동일한 패턴**: 단건 조회의 존재/부재 분기는 모든 도메인에서 동일하게 적용된다. + +--- + +### US-P03: 상품 목록 조회 (관리자) + +#### 검증 목적 + +관리자 목록 조회가 고객 목록 조회(US-P01)와 동일한 흐름을 따르되, 응답 정보의 범위만 다른지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as ProductAdminV1Controller + participant Facade as ProductAdminFacade + participant Service as ProductService + participant Repository as ProductRepository + + 관리자->>Controller: 상품 목록 조회 요청 (브랜드 필터) + Controller->>Facade: 상품 목록 조회 위임 + Facade->>Service: 상품 목록 조회 + Service->>Repository: 조건부 목록 조회 + Repository-->>Service: 상품 목록 + Service-->>Facade: 상품 목록 + Facade-->>Controller: 상품 목록 + Controller-->>관리자: 상품 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **BR-P05**: 관리자에게는 재고 수량까지 노출된다. 고객용/관리자용 차이는 Controller의 DTO 변환에서 결정된다. + +--- + +### US-P04: 상품 상세 조회 (관리자) + +#### 검증 목적 + +관리자 단건 조회가 US-P02와 동일한 흐름을 따르는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as ProductAdminV1Controller + participant Facade as ProductAdminFacade + participant Service as ProductService + participant Repository as ProductRepository + + 관리자->>Controller: 상품 상세 조회 요청 + Controller->>Facade: 상품 조회 위임 + Facade->>Service: 상품 조회 + Service->>Repository: 상품 존재 여부 확인 + + alt 상품이 존재하는 경우 + Repository-->>Service: 상품 정보 + Service-->>Facade: 상품 정보 + Facade-->>Controller: 상품 정보 + Controller-->>관리자: 상품 상세 정보 응답 + else 상품이 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end +``` + +#### 봐야 할 포인트 + +1. **US-P02와 동일한 흐름**: 액터와 응답 DTO 범위만 다르다. + +--- + +### US-P05: 상품 등록 (관리자) + +#### 검증 목적 + +상품 등록 시 두 가지를 검증한다: BR-P01(브랜드 존재 여부)은 Facade가 도메인 간 조율로 처리하고, 같은 브랜드 내 상품명 중복은 ProductService가 내부에서 처리한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as ProductAdminV1Controller + participant Facade as ProductAdminFacade + participant BrandService + participant BrandRepository + participant ProductService + participant ProductRepository + + 관리자->>Controller: 상품 등록 요청 + Controller->>Facade: 상품 등록 위임 + Facade->>BrandService: 브랜드 존재 확인 + BrandService->>BrandRepository: 브랜드 존재 여부 확인 + + alt 브랜드가 존재하지 않는 경우 + BrandRepository-->>BrandService: 없음 + BrandService->>BrandService: 비즈니스 예외 발생 + BrandService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 브랜드가 존재하지 않음 안내 + end + + BrandRepository-->>BrandService: 브랜드 정보 + BrandService-->>Facade: 브랜드 정보 + Facade->>ProductService: 상품 등록 + ProductService->>ProductRepository: 같은 브랜드 내 상품명 중복 여부 확인 + + alt 상품명 중복 시 + ProductRepository-->>ProductService: 상품명 중복 + ProductService-->>Facade: 중복 예외 + Facade-->>Controller: 중복 예외 전파 + Controller-->>관리자: 409 Conflict + end + + ProductRepository-->>ProductService: 중복되지 않음 + ProductService->>ProductRepository: 상품 정보 저장 + ProductRepository-->>ProductService: 저장된 상품 정보 + ProductService-->>Facade: 상품 정보 + Facade-->>Controller: 상품 정보 + Controller-->>관리자: 상품 등록 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **검증 책임의 분리**: 브랜드 존재 확인(도메인 간)은 Facade가, 상품명 중복 확인(도메인 내)은 ProductService가 처리한다. US-B04의 브랜드명 중복 검증과 동일한 패턴. +2. **중복 범위**: 상품명 중복은 전체가 아닌 **같은 브랜드 내**에서만 확인한다. 다른 브랜드에 동일한 상품명이 존재하는 것은 허용된다. + +--- + +### US-P06: 상품 정보 수정 (관리자) + +#### 검증 목적 + +상품 수정 시 "존재 여부 확인 → 같은 브랜드 내 상품명 중복 확인 → 수정" 순서를 확인한다. BR-P02(소속 브랜드 변경 불가)는 도메인 모델 수준에서 보장된다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as ProductAdminV1Controller + participant Facade as ProductAdminFacade + participant Service as ProductService + participant Repository as ProductRepository + + 관리자->>Controller: 상품 수정 요청 + Controller->>Facade: 상품 수정 위임 + Facade->>Service: 상품 수정 + Service->>Repository: 상품 존재 여부 확인 + + alt 상품이 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end + + Repository-->>Service: 상품 정보 + Service->>Repository: 같은 브랜드 내 상품명 중복 여부 확인 + + alt 상품명 중복 시 + Repository-->>Service: 상품명 중복 + Service-->>Facade: 중복 예외 + Facade-->>Controller: 중복 예외 전파 + Controller-->>관리자: 409 Conflict + end + + Repository-->>Service: 중복되지 않음 + Service->>Repository: 상품 수정 + Repository-->>Service: 수정된 상품 정보 + Service-->>Facade: 상품 정보 + Facade-->>Controller: 상품 정보 + Controller-->>관리자: 상품 수정 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **이중 검증**: US-B05(브랜드 수정)와 동일한 패턴. 존재 확인 → 상품명 중복 확인 → 수정. +2. **자기 자신 제외**: 상품명 중복 확인 시 자기 자신은 제외해야 한다. Repository 쿼리에서 처리. +3. **BR-P02 브랜드 불변성**: 소속 브랜드 변경은 도메인 모델이 브랜드 변경 setter를 제공하지 않는 방식으로 보장한다. 시퀀스 다이어그램의 관심사(컴포넌트 간 흐름)가 아닌 모델 설계의 관심사이다. + +--- + +### US-P07: 상품 삭제 (관리자) + +#### 검증 목적 + +상품 삭제 시 해당 상품의 좋아요도 함께 정리해야 한다. 상품은 soft delete, 좋아요는 hard delete로 삭제 정책이 다르므로 Facade가 ProductService와 LikeService를 조율하는 cross-domain 처리이다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as ProductAdminV1Controller + participant Facade as ProductAdminFacade + participant ProductService + participant ProductRepository + participant LikeService + participant LikeRepository + participant CartService + participant CartRepository + + 관리자->>Controller: 상품 삭제 요청 + Controller->>Facade: 상품 삭제 위임 + Facade->>ProductService: 상품 존재 확인 + ProductService->>ProductRepository: 상품 존재 여부 확인 + + alt 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: 없음 + ProductService->>ProductService: 비즈니스 예외 발생 + ProductService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end + + ProductRepository-->>ProductService: 상품 정보 + ProductService-->>Facade: 상품 정보 + Facade->>LikeService: 해당 상품 좋아요 전체 삭제 + LikeService->>LikeRepository: 좋아요 전체 삭제 (hard delete) + LikeRepository-->>LikeService: 삭제 완료 + LikeService-->>Facade: 삭제 완료 + Facade->>CartService: 해당 상품 장바구니 항목 전체 삭제 + CartService->>CartRepository: 장바구니 항목 전체 삭제 (hard delete) + CartRepository-->>CartService: 삭제 완료 + CartService-->>Facade: 삭제 완료 + Facade->>ProductService: 상품 삭제 + ProductService->>ProductRepository: 상품 삭제 (soft delete) + ProductRepository-->>ProductService: 삭제 완료 + ProductService-->>Facade: 삭제 완료 + Facade-->>Controller: 삭제 완료 + Controller-->>관리자: 상품 삭제 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **삭제 정책의 혼합**: 좋아요(hard delete) → 장바구니 항목(hard delete) → 상품(soft delete) 순서로 처리한다. 종속 데이터를 먼저 정리해야 soft delete된 상품에 고아 데이터가 남는 불일치를 방지한다. +2. **US-B06과 동일한 패턴**: 브랜드 삭제 시 상품을 정리하듯, 상품 삭제 시 좋아요와 장바구니 항목을 정리한다. Facade가 도메인 간 삭제 순서를 결정한다. + +#### 상품 도메인 잠재 리스크 + +- **검증 위치의 기준**: 도메인 간 검증(브랜드 존재 확인)은 Facade가, 도메인 내부 규칙(상품명 중복, 브랜드 불변성)은 Service/Model이 처리한다. + +--- + +## 2.3 좋아요 (Like) + +> **삭제 정책**: 좋아요는 **hard delete**를 사용한다. 브랜드/상품과 달리 이력으로서 보존할 가치가 없으므로 물리적으로 삭제한다. + +### US-L01: 상품 좋아요 등록 + +#### 검증 목적 + +좋아요 등록 시 세 가지를 처리한다: 상품 존재 확인(도메인 간), 중복 좋아요 확인(BR-L01), 그리고 상품의 좋아요 수 업데이트. Facade가 LikeService와 ProductService를 조율하는 흐름을 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as LikeV1Controller + participant Facade as LikeFacade + participant ProductService + participant ProductRepository + participant LikeService + participant LikeRepository + + 회원->>Controller: 좋아요 등록 요청 + Controller->>Facade: 좋아요 등록 위임 + Facade->>ProductService: 상품 존재 확인 + ProductService->>ProductRepository: 상품 존재 여부 확인 + + alt 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: 없음 + ProductService->>ProductService: 비즈니스 예외 발생 + ProductService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 상품이 존재하지 않음 안내 + end + + ProductRepository-->>ProductService: 상품 정보 + ProductService-->>Facade: 상품 정보 + Facade->>LikeService: 좋아요 등록 + LikeService->>LikeRepository: 좋아요 존재 여부 확인 + + alt 이미 좋아요한 상품인 경우 + LikeRepository-->>LikeService: 좋아요 존재 + LikeService->>LikeService: 비즈니스 예외 발생 + LikeService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 이미 좋아요 상태임 안내 + end + + LikeRepository-->>LikeService: 없음 + LikeService->>LikeRepository: 좋아요 저장 + LikeRepository-->>LikeService: 저장 완료 + LikeService-->>Facade: 좋아요 등록 완료 + Facade->>ProductService: 좋아요 수 증가 + ProductService->>ProductRepository: 좋아요 수 업데이트 + ProductRepository-->>ProductService: 업데이트 완료 + ProductService-->>Facade: 업데이트 완료 + Facade-->>Controller: 등록 완료 + Controller-->>회원: 좋아요 등록 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **Facade의 3단계 조율**: 상품 존재 확인(ProductService) → 좋아요 등록(LikeService) → 좋아요 수 증가(ProductService). 세 단계를 하나의 트랜잭션으로 묶어야 한다. +2. **좋아요 수 업데이트 시점**: 좋아요 등록이 성공한 후에 수를 증가시킨다. 등록 실패(중복) 시에는 수를 변경하지 않는다. + +--- + +### US-L02: 상품 좋아요 취소 + +#### 검증 목적 + +좋아요 취소 시 좋아요 레코드를 hard delete하고, 상품의 좋아요 수를 감소시키는 흐름을 확인한다. 좋아요 취소는 도메인 간 조율이 필요한 cross-domain 처리이다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as LikeV1Controller + participant Facade as LikeFacade + participant LikeService + participant LikeRepository + participant ProductService + participant ProductRepository + + 회원->>Controller: 좋아요 취소 요청 + Controller->>Facade: 좋아요 취소 위임 + Facade->>LikeService: 좋아요 취소 + LikeService->>LikeRepository: 좋아요 존재 여부 확인 + + alt 좋아요가 존재하지 않는 경우 + LikeRepository-->>LikeService: 없음 + LikeService->>LikeService: 비즈니스 예외 발생 + LikeService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 좋아요 상태가 아님 안내 + end + + LikeRepository-->>LikeService: 좋아요 정보 + LikeService->>LikeRepository: 좋아요 삭제 (hard delete) + LikeRepository-->>LikeService: 삭제 완료 + LikeService-->>Facade: 삭제 완료 + Facade->>ProductService: 좋아요 수 감소 + ProductService->>ProductRepository: 좋아요 수 업데이트 + ProductRepository-->>ProductService: 업데이트 완료 + ProductService-->>Facade: 업데이트 완료 + Facade-->>Controller: 취소 완료 + Controller-->>회원: 좋아요 취소 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **hard delete**: 좋아요 레코드는 물리적으로 삭제된다. soft delete와 달리 복원이 불가능하지만, 좋아요는 이력 보존이 불필요하다. +2. **좋아요 수 감소**: 삭제 성공 후 상품의 좋아요 수를 감소시킨다. US-L01의 역연산. + +--- + +### US-L03: 좋아요한 상품 목록 조회 + +#### 검증 목적 + +회원이 자신의 좋아요 목록만 조회할 수 있는지(BR-L03) 확인한다. 목록 조회이므로 예외 분기가 없다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as LikeV1Controller + participant Facade as LikeFacade + participant Service as LikeService + participant Repository as LikeRepository + + 회원->>Controller: 좋아요 목록 조회 요청 + Controller->>Facade: 좋아요 목록 조회 위임 + Facade->>Service: 좋아요 목록 조회 + Service->>Repository: 회원의 좋아요 목록 조회 + Repository-->>Service: 좋아요 목록 + Service-->>Facade: 좋아요 목록 + Facade-->>Controller: 좋아요 목록 + Controller-->>회원: 좋아요 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **BR-L03 소유권 제한**: 인증된 회원 ID를 기준으로 자신의 좋아요만 조회한다. Controller에서 인증 정보를 추출하여 전달한다. + +#### 좋아요 도메인 잠재 리스크 + +- **좋아요 수와 실제 레코드의 정합성**: 좋아요 수(Product 컬럼)와 실제 Like 레코드 수가 어긋날 수 있다. 트랜잭션 내에서 원자적으로 처리하되, 장기적으로는 배치로 보정하는 방어 전략도 고려할 수 있다. +- **좋아요 수 동시성**: 동일 상품에 여러 회원이 동시에 좋아요하면 좋아요 수 컬럼에 경합이 발생한다. 낙관적 잠금(@Version) 또는 원자적 증감(UPDATE SET count = count + 1)으로 방어가 필요하다. +- **동시 좋아요 요청**: 같은 회원이 동일 상품에 동시에 좋아요 요청을 보내면 중복이 발생할 수 있다. 유니크 제약 조건(DB 레벨)으로 방어하는 것이 안전하다. + +--- + +## 2.4 장바구니 (Cart) + +### US-C01: 장바구니에 상품 담기 + +#### 검증 목적 + +BR-C02에 따라 이미 장바구니에 있는 상품을 다시 담으면 수량이 누적된다. "신규 추가"와 "수량 누적"의 분기 처리 책임이 어느 계층에 있는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as CartV1Controller + participant Facade as CartFacade + participant ProductService + participant ProductRepository + participant CartService + participant CartRepository + + 회원->>Controller: 장바구니 담기 요청 (상품, 수량) + Controller->>Facade: 장바구니 담기 위임 + Facade->>ProductService: 상품 존재 확인 + ProductService->>ProductRepository: 상품 존재 여부 확인 + + alt 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: 없음 + ProductService->>ProductService: 비즈니스 예외 발생 + ProductService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 상품이 존재하지 않음 안내 + end + + ProductRepository-->>ProductService: 상품 정보 + ProductService-->>Facade: 상품 정보 + Facade->>CartService: 장바구니에 상품 담기 + CartService->>CartRepository: 장바구니 항목 조회 + + alt 이미 장바구니에 있는 상품인 경우 + CartRepository-->>CartService: 기존 장바구니 항목 + CartService->>CartRepository: 수량 누적 후 저장 + CartRepository-->>CartService: 저장 완료 + else 새로운 상품인 경우 + CartRepository-->>CartService: 없음 + CartService->>CartRepository: 새 장바구니 항목 저장 + CartRepository-->>CartService: 저장 완료 + end + + CartService-->>Facade: 담기 완료 + Facade-->>Controller: 담기 완료 + Controller-->>회원: 장바구니 담기 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **수량 누적 판단의 책임**: CartService가 CartRepository를 통해 기존 항목 존재 여부를 확인하고, 존재하면 수량을 누적, 없으면 새 항목을 생성한다. Facade는 "담기"를 요청할 뿐, 신규/누적 분기를 알 필요가 없다. +2. **상품 검증은 Facade 책임**: 도메인 간 검증(상품 존재)은 US-P05, US-L01과 동일하게 Facade가 조율한다. + +--- + +### US-C02: 장바구니 조회 + +#### 검증 목적 + +회원이 자신의 장바구니만 조회하는 흐름(BR-C04)을 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as CartV1Controller + participant Facade as CartFacade + participant Service as CartService + participant Repository as CartRepository + + 회원->>Controller: 장바구니 조회 요청 + Controller->>Facade: 장바구니 조회 위임 + Facade->>Service: 장바구니 조회 + Service->>Repository: 회원의 장바구니 항목 조회 + Repository-->>Service: 장바구니 항목 목록 + Service-->>Facade: 장바구니 항목 목록 + Facade-->>Controller: 장바구니 항목 목록 + Controller-->>회원: 장바구니 조회 응답 +``` + +#### 봐야 할 포인트 + +1. **BR-C04 소유권 제한**: 인증된 회원 ID 기준으로 자신의 장바구니만 조회한다. +2. **빈 장바구니도 정상 응답**: 장바구니에 항목이 없어도 빈 목록으로 정상 응답한다. + +--- + +### US-C03: 장바구니 상품 수량 변경 + +#### 검증 목적 + +수량 변경 시 BR-C03(수량 1 이상)과 장바구니 항목 존재 여부를 어느 계층에서 검증하는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as CartV1Controller + participant Facade as CartFacade + participant Service as CartService + participant Repository as CartRepository + + 회원->>Controller: 수량 변경 요청 (상품, 새 수량) + Controller->>Facade: 수량 변경 위임 + Facade->>Service: 수량 변경 + Service->>Repository: 장바구니 항목 조회 + + alt 장바구니에 해당 상품이 없는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 장바구니에 해당 상품 없음 안내 + end + + Repository-->>Service: 장바구니 항목 + Service->>Repository: 수량 변경 + Repository-->>Service: 변경 완료 + Service-->>Facade: 변경 완료 + Facade-->>Controller: 변경 완료 + Controller-->>회원: 수량 변경 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **BR-C03 수량 검증 위치**: 수량이 1 이상인지 검증은 Service 또는 도메인 모델에서 처리한다. Controller의 요청 검증(@Valid)에서 먼저 걸러낼 수도 있다. + +--- + +### US-C04: 장바구니 상품 제거 + +#### 검증 목적 + +장바구니 항목 제거의 흐름을 확인한다. 존재하지 않는 항목 제거 시도에 대한 예외 처리를 검증한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as CartV1Controller + participant Facade as CartFacade + participant Service as CartService + participant Repository as CartRepository + + 회원->>Controller: 장바구니 상품 제거 요청 + Controller->>Facade: 상품 제거 위임 + Facade->>Service: 장바구니 항목 제거 + Service->>Repository: 장바구니 항목 조회 + + alt 장바구니에 해당 상품이 없는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 장바구니에 해당 상품 없음 안내 + end + + Repository-->>Service: 장바구니 항목 + Service->>Repository: 항목 삭제 + Repository-->>Service: 삭제 완료 + Service-->>Facade: 제거 완료 + Facade-->>Controller: 제거 완료 + Controller-->>회원: 상품 제거 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **US-C03과 동일한 전제**: 장바구니 항목의 존재 여부를 먼저 확인한다. early-return으로 예외를 빼고 정상 흐름은 블록 바깥에 둔다. + +#### 장바구니 도메인 잠재 리스크 + +- **수량 누적의 상한**: BR-C02에서 수량 누적에 상한이 없다. 재고보다 많은 수량을 장바구니에 담는 것을 허용할지, 담기 시점에 재고를 검증할지 결정이 필요하다. + +--- + +## 2.5 주문 (Order) + +### US-O01: 주문 생성 + +#### 검증 목적 + +가장 복잡한 흐름이다. BR-O01~O05가 모두 적용된다. 여러 상품의 재고 확인 → 주문 생성(스냅샷 포함) → 재고 차감이 하나의 트랜잭션으로 처리되어야 하며, Facade가 ProductService와 OrderService를 조율하는 흐름을 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant ProductService + participant ProductRepository + participant OrderService + participant OrderRepository + + 회원->>Controller: 주문 요청 (상품 목록, 수량) + Controller->>Facade: 주문 생성 위임 + Facade->>ProductService: 상품 존재 및 재고 확인 + ProductService->>ProductRepository: 상품 존재 여부 확인 + + alt 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: 없음 + ProductService->>ProductService: 비즈니스 예외 발생 + ProductService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 존재하지 않는 상품 안내 + end + + ProductRepository-->>ProductService: 상품 정보 + ProductService->>ProductService: 재고 충분 여부 확인 + + alt 재고가 부족한 경우 + ProductService->>ProductService: 비즈니스 예외 발생 + ProductService-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 재고 부족 안내 + end + + ProductService-->>Facade: 상품 정보 (스냅샷용) + Facade->>OrderService: 주문 생성 (스냅샷 포함) + OrderService->>OrderRepository: 주문 정보 저장 + OrderRepository-->>OrderService: 저장된 주문 정보 + OrderService-->>Facade: 주문 정보 + Facade->>ProductService: 재고 차감 + ProductService->>ProductRepository: 재고 수량 업데이트 + ProductRepository-->>ProductService: 업데이트 완료 + ProductService-->>Facade: 차감 완료 + Facade-->>Controller: 주문 정보 + Controller-->>회원: 주문 완료 응답 +``` + +#### 봐야 할 포인트 + +1. **Facade의 핵심 조율**: 이 시나리오에서 Facade의 존재 의미가 가장 명확하다. 재고 확인 → 주문 생성 → 재고 차감을 하나의 유스케이스로 조율한다. +2. **스냅샷 생성 시점**: ProductService에서 받은 상품 정보를 OrderService에 전달하여 스냅샷으로 보존한다. 스냅샷은 주문 시점의 상품명, 가격, 브랜드 등을 포함한다(BR-O05). +3. **재고 차감 순서**: 주문 생성 후 재고를 차감한다. 만약 재고 차감이 먼저라면, 주문 생성 실패 시 차감을 복원해야 하는 보상 로직이 필요해진다. + +--- + +### US-O02: 주문 목록 조회 (회원) + +#### 검증 목적 + +BR-O06(자신의 주문만 조회)과 BR-O08(기간 필터)이 적용되는 흐름을 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant Service as OrderService + participant Repository as OrderRepository + + 회원->>Controller: 주문 목록 조회 요청 (시작일, 종료일) + Controller->>Facade: 주문 목록 조회 위임 + Facade->>Service: 주문 목록 조회 + Service->>Repository: 회원의 주문 목록 조회 (기간 필터) + Repository-->>Service: 주문 목록 + Service-->>Facade: 주문 목록 + Facade-->>Controller: 주문 목록 + Controller-->>회원: 주문 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **BR-O06 + BR-O08**: 인증된 회원 ID와 기간 조건을 조합하여 조회한다. 기간이 지정되지 않은 경우의 기본값 정책도 결정이 필요하다. + +--- + +### US-O03: 주문 상세 조회 (회원) + +#### 검증 목적 + +회원이 자신의 주문만 조회할 수 있는지(BR-O06), 타인의 주문 접근 시 거부되는지 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 회원 + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant Service as OrderService + participant Repository as OrderRepository + + 회원->>Controller: 주문 상세 조회 요청 + Controller->>Facade: 주문 조회 위임 + Facade->>Service: 주문 조회 + Service->>Repository: 주문 조회 + + alt 주문이 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 존재하지 않음 안내 + end + + Repository-->>Service: 주문 정보 + Service->>Service: 본인 주문 여부 확인 + + alt 다른 회원의 주문인 경우 + Service->>Service: 접근 거부 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>회원: 접근 거부 안내 + end + + Service-->>Facade: 주문 정보 (스냅샷 포함) + Facade-->>Controller: 주문 정보 + Controller-->>회원: 주문 상세 정보 응답 +``` + +#### 봐야 할 포인트 + +1. **이중 검증**: 존재 확인 → 소유권 확인의 2단계 early-return. "존재하지 않음"(NOT_FOUND)과 "접근 거부"(FORBIDDEN)는 다른 예외 타입이다. +2. **스냅샷 정보 포함**: 주문 상세에는 주문 당시의 상품/브랜드 정보(스냅샷)가 포함된다. + +--- + +### US-O04: 주문 목록 조회 (관리자) + +#### 검증 목적 + +관리자가 전체 주문을 페이지 단위로 조회하는 흐름(BR-O07)을 확인한다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as OrderAdminV1Controller + participant Facade as OrderAdminFacade + participant Service as OrderService + participant Repository as OrderRepository + + 관리자->>Controller: 전체 주문 목록 조회 요청 (페이징) + Controller->>Facade: 주문 목록 조회 위임 + Facade->>Service: 전체 주문 목록 조회 + Service->>Repository: 전체 주문 목록 조회 (페이징) + Repository-->>Service: 주문 목록 + Service-->>Facade: 주문 목록 + Facade-->>Controller: 주문 목록 + Controller-->>관리자: 주문 목록 응답 +``` + +#### 봐야 할 포인트 + +1. **소유권 제한 없음**: 관리자는 BR-O07에 따라 전체 주문을 조회할 수 있다. 회원 조회(US-O02)와 달리 소유권 필터가 없다. + +--- + +### US-O05: 주문 상세 조회 (관리자) + +#### 검증 목적 + +관리자의 단건 주문 조회를 확인한다. 회원 조회(US-O03)와 달리 소유권 확인이 불필요하다. + +#### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor 관리자 + participant Controller as OrderAdminV1Controller + participant Facade as OrderAdminFacade + participant Service as OrderService + participant Repository as OrderRepository + + 관리자->>Controller: 주문 상세 조회 요청 + Controller->>Facade: 주문 조회 위임 + Facade->>Service: 주문 조회 + Service->>Repository: 주문 조회 + + alt 주문이 존재하지 않는 경우 + Repository-->>Service: 없음 + Service->>Service: 비즈니스 예외 발생 + Service-->>Facade: 예외 전파 + Facade-->>Controller: 예외 전파 + Controller-->>관리자: 존재하지 않음 안내 + end + + Repository-->>Service: 주문 정보 + Service-->>Facade: 주문 정보 (스냅샷 포함) + Facade-->>Controller: 주문 정보 + Controller-->>관리자: 주문 상세 정보 응답 +``` + +#### 봐야 할 포인트 + +1. **US-O03과의 차이**: 소유권 검증이 없다. 관리자는 모든 주문에 접근 가능하다. US-O03의 이중 검증과 달리 존재 확인만 수행한다. + +#### 주문 도메인 잠재 리스크 + +- **재고 차감의 원자성**: US-O01에서 재고 확인 → 주문 생성 → 재고 차감이 하나의 트랜잭션이어야 한다. 동시에 여러 주문이 같은 상품을 주문하면 재고가 음수가 될 수 있으므로, 비관적 잠금(SELECT FOR UPDATE) 또는 낙관적 잠금(@Version) 전략이 필요하다. +- **트랜잭션 범위의 비대화**: 주문 생성 트랜잭션이 ProductService(재고 확인/차감)와 OrderService(주문 생성)를 모두 포함하므로 범위가 넓다. 상품 수가 많으면 잠금 시간이 길어질 수 있다. +- **스냅샷 데이터의 정합성**: 스냅샷은 주문 시점의 데이터 사본이다. ProductService에서 상품 정보를 조회한 시점과 실제 저장 시점 사이에 상품 정보가 변경될 가능성은 트랜잭션으로 방어한다. diff --git a/.docs/design/03-class-diagrams.md b/.docs/design/03-class-diagrams.md new file mode 100644 index 000000000..cb4f4f1af --- /dev/null +++ b/.docs/design/03-class-diagrams.md @@ -0,0 +1,370 @@ +# 클래스 다이어그램 + +## 공통 사항 + +### BaseEntity + +모든 soft delete 정책 엔티티가 상속하는 추상 클래스이다. + +- `id` (Long): `@GeneratedValue(IDENTITY)` 자동 생성 +- `createdAt`, `updatedAt`: `@PrePersist`/`@PreUpdate` 자동 관리 +- `deletedAt`: soft delete 마커 (`delete()`, `restore()` 멱등 연산) +- `guard()`: 하위 클래스가 재정의하여 `@PrePersist`/`@PreUpdate` 시점에 불변 조건을 검증 + +### VO 설계 원칙 + +- **ID 없음**: 고유 식별자를 갖지 않는다. 속성 값으로만 동등성을 판단한다. +- **불변**: 생성 후 내부 상태가 변하지 않는다. 상태 변경이 필요하면 새 인스턴스를 반환한다. +- **분리 기준**: 2개 이상 도메인에서 재사용되거나, 자체 행위/검증 규칙이 있을 때 VO로 분리한다. + +| VO | 사용처 | 분리 근거 | +|----|--------|----------| +| Money | Product.price, OrderItem.price | 2곳 재사용 + 음수 불가 검증 | +| Stock | Product.stock | 자체 행위 3개 (decrease, increase, hasEnough) → Product 책임 분산 | +| Quantity | CartItem.quantity, OrderItem.quantity | 2곳 재사용 + >= 1 검증 (BR-C03, BR-O02) | + +### 연관 관계 원칙 + +- **모두 단방향**, ID 참조(Long)로 느슨하게 연결한다. +- 유일한 composition: **Order ◆── OrderItem** (Order가 Aggregate Root) +- 양방향 관계는 사용하지 않는다. + +--- + +## 전체 도메인 관계도 + +### 검증 목적 + +엔티티 간 관계, BaseEntity 상속 여부, VO 소속을 한눈에 파악한다. + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +guard() void + +delete() void + +restore() void + } + + BaseEntity <|-- Brand + BaseEntity <|-- Product + BaseEntity <|-- Order + BaseEntity <|-- OrderItem + + Brand "1" <.. "*" Product : brandId + Product "1" <.. "*" Like : productId + Product "1" <.. "*" CartItem : productId + Product "1" <.. "*" OrderItem : productId + Order "1" *-- "1..*" OrderItem : orderItems + + Product *-- Money : price + Product *-- Stock : stock + CartItem *-- Quantity : quantity + OrderItem *-- Quantity : quantity + OrderItem *-- Money : price +``` + +### 봐야 할 포인트 + +1. **Like, CartItem은 BaseEntity 미상속**: 둘 다 hard delete 정책이므로 `deletedAt`이 불필요하다. BaseEntity를 상속하면 사용하지 않는 `deletedAt` 컬럼과 `delete()`/`restore()` 메서드가 노출되어 상속 계약을 위반한다. 나머지 4개 엔티티(Brand, Product, Order, OrderItem)는 soft delete를 사용한다. +2. **ID 참조**: 점선 화살표(`<..`)는 Long 타입 ID로 참조하는 느슨한 연관이다. JPA `@ManyToOne`이 아닌 `Long brandId` 필드로 표현된다. +3. **유일한 composition**: Order → OrderItem만 실선 다이아몬드(`*--`)로 표현한다. OrderItem은 Order 없이 존재할 수 없다. + +--- + +## 브랜드 (Brand) + +### 검증 목적 + +가장 단순한 엔티티로, BaseEntity 상속과 `guard()` 재정의 패턴의 기본 형태를 확인한다. + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +guard() void + +delete() void + +restore() void + } + + class Brand { + -String name + +Brand(String name) + +update(String name) void + #guard() void + } + + BaseEntity <|-- Brand +``` + +### 봐야 할 포인트 + +1. **단일 필드**: `name` 하나만 관리한다. VO로 분리할 만큼 복잡한 검증 규칙이 없으므로 primitive `String`으로 유지한다. +2. **guard() 재정의**: `@PrePersist`/`@PreUpdate` 시점에 `name`이 null이거나 비어있지 않은지 검증한다. +3. **update() 메서드**: `name`만 수정 가능하다. 시퀀스 다이어그램(US-B05)에서 브랜드명 중복 확인은 Service 책임이며, 모델은 값의 유효성만 검증한다. + +--- + +## 상품 (Product + Money VO + Stock VO) + +### 검증 목적 + +4개의 관심사(name, price, stock, likeCount)를 가진 엔티티에서 VO를 통해 책임을 분산하는 패턴을 확인한다. BR-P02(브랜드 불변성)가 메서드 시그니처 수준에서 어떻게 보장되는지 확인한다. + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +guard() void + +delete() void + +restore() void + } + + class Product { + -Long brandId + -String name + -Money price + -Stock stock + -int likeCount + +Product(Long brandId, String name, Money price, Stock stock) + +update(String name, Money price, Stock stock) void + +decreaseStock(Quantity quantity) void + +increaseStock(Quantity quantity) void + +increaseLikeCount() void + +decreaseLikeCount() void + #guard() void + } + + class Money { + <> + -int amount + +Money(int amount) + } + + class Stock { + <> + -int quantity + +Stock(int quantity) + +decrease(Quantity quantity) Stock + +increase(Quantity quantity) Stock + +hasEnough(Quantity quantity) boolean + } + + class Quantity { + <> + -int value + +Quantity(int value) + } + + BaseEntity <|-- Product + Product *-- Money : price + Product *-- Stock : stock + Stock ..> Quantity : uses + Product ..> Quantity : uses +``` + +### 봐야 할 포인트 + +1. **BR-P02 브랜드 불변성**: `update()` 메서드의 파라미터에 `brandId`가 없다. `brandId`를 변경할 수 있는 메서드가 존재하지 않으므로, 메서드 시그니처 수준에서 불변성이 보장된다. +2. **Money VO**: `amount`는 0 이상이어야 한다. 생성자에서 음수 검증을 수행한다. `Product.price`와 `OrderItem.price`에서 재사용된다. +3. **Stock VO**: 재고 관련 행위 3개(`decrease`, `increase`, `hasEnough`)를 캡슐화한다. 불변이므로 `decrease`/`increase`는 새 `Stock` 인스턴스를 반환한다. Product에서 재고 관련 로직을 분리하여 책임을 분산한다. +4. **Quantity VO**: `value >= 1` 검증을 생성자에서 수행한다 (BR-C03, BR-O02). `CartItem`과 `OrderItem`에서 재사용된다. +5. **likeCount는 primitive 유지**: 단순 증감만 수행하므로 VO로 분리할 근거가 없다. 증감 메서드(`increaseLikeCount`, `decreaseLikeCount`)를 Product가 직접 제공한다. +6. **Product.decreaseStock / increaseStock**: Stock VO에 위임하되, Product의 행위 메서드로 외부에 노출한다. Product가 "재고를 줄인다"는 도메인 의미를 유지하면서, 실제 로직은 Stock VO가 처리한다. + +--- + +## 좋아요 (Like) + +### 검증 목적 + +BaseEntity를 상속하지 않는 유일한 엔티티이다. hard delete 정책에 따라 자체적으로 `id`와 `createdAt`만 보유하는 구조를 확인한다. + +### 다이어그램 + +```mermaid +classDiagram + class Like { + -Long id + -Long userId + -Long productId + -ZonedDateTime createdAt + +Like(Long userId, Long productId) + } + + note for Like "BaseEntity 미상속\nhard delete 정책\nupdatedAt/deletedAt 불필요" +``` + +### 봐야 할 포인트 + +1. **BaseEntity 미상속 이유**: Like는 (1) hard delete 정책이므로 `deletedAt`이 불필요하고, (2) 생성 후 수정이 없으므로 `updatedAt`이 불필요하다. BaseEntity의 4개 필드 중 `id`와 `createdAt`만 필요하므로 자체 필드로 선언한다. +2. **행위 메서드 없음**: Like는 생성과 삭제만 존재한다. 상태 변경이 없으므로 행위 메서드가 불필요하다. +3. **유니크 제약**: `(userId, productId)` 조합이 유일해야 한다 (BR-L01). 이는 DB 유니크 제약과 Service 레벨 중복 검증으로 이중 방어한다. +4. **ID 참조**: `userId`와 `productId`는 Long 타입으로 느슨하게 참조한다. User, Product 엔티티에 대한 직접 참조(`@ManyToOne`)를 사용하지 않는다. + +--- + +## 장바구니 (CartItem + Quantity VO) + +### 검증 목적 + +CartItem이 Quantity VO를 통해 수량 검증(BR-C03)을 위임하는 구조와, 수량 누적(BR-C02)이 도메인 모델의 행위 메서드로 표현되는지 확인한다. Like와 마찬가지로 hard delete 정책이므로 BaseEntity를 상속하지 않는다. + +### 다이어그램 + +```mermaid +classDiagram + class CartItem { + -Long id + -Long userId + -Long productId + -Quantity quantity + -ZonedDateTime createdAt + +CartItem(Long userId, Long productId, Quantity quantity) + +addQuantity(Quantity quantity) void + +changeQuantity(Quantity quantity) void + } + + class Quantity { + <> + -int value + +Quantity(int value) + +add(Quantity other) Quantity + } + + CartItem *-- Quantity : quantity + + note for CartItem "BaseEntity 미상속\nhard delete 정책\ndeletedAt 불필요" +``` + +### 봐야 할 포인트 + +1. **BaseEntity 미상속 이유**: CartItem은 hard delete 정책이다. 회원이 장바구니에서 상품을 제거하면 물리 삭제하며, 관리자가 상품/브랜드를 삭제할 때도 해당 장바구니 항목을 물리 삭제한다. 이력 보존이 불필요하므로 `deletedAt`이 필요 없고, BaseEntity의 `delete()`/`restore()` 메서드가 노출되면 안 된다. +2. **addQuantity()**: BR-C02(수량 누적)를 구현한다. 이미 장바구니에 있는 상품을 다시 담으면, CartService가 기존 CartItem의 `addQuantity()`를 호출하여 수량을 누적한다. 내부적으로 Quantity VO의 `add()`에 위임한다. +3. **changeQuantity()**: US-C03(수량 변경)을 구현한다. 새 Quantity를 받아 교체한다. Quantity 생성자에서 `value >= 1` 검증이 수행되므로, 0 이하 수량은 VO 레벨에서 거부된다. +4. **Quantity.add()**: 불변 VO이므로 두 Quantity의 합산 결과를 새 인스턴스로 반환한다. `value >= 1` 검증은 생성자에서 수행되므로 `add()` 결과도 자동으로 유효하다. +5. **Cart 엔티티 없음**: BR-C01("회원은 하나의 장바구니를 가진다")이지만, 장바구니 자체를 엔티티로 두지 않고 `CartItem.userId`로 회원의 장바구니를 식별한다. CartItem의 집합이 곧 해당 회원의 장바구니이다. + +--- + +## 주문 (Order + OrderItem) + +### 검증 목적 + +가장 복잡한 도메인이다. Order가 Aggregate Root로서 OrderItem을 포함한다. OrderItem 자체가 주문 시점의 상품 정보를 보존하는 스냅샷 역할을 한다. BR-O01(최소 1개 항목), BR-O06(소유권 검증)이 도메인 모델에서 어떻게 보장되는지 확인한다. + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +guard() void + +delete() void + +restore() void + } + + class Order { + -Long userId + -List~OrderItem~ orderItems + +Order(Long userId, List~OrderItem~ orderItems) + +isOwnedBy(Long userId) boolean + #guard() void + } + + class OrderItem { + -Long orderId + -Long productId + -Quantity quantity + -String productName + -String brandName + -Money price + +OrderItem(Long productId, Quantity quantity, String productName, String brandName, Money price) + #guard() void + } + + class Quantity { + <> + -int value + +Quantity(int value) + } + + class Money { + <> + -int amount + +Money(int amount) + } + + BaseEntity <|-- Order + BaseEntity <|-- OrderItem + Order "1" *-- "1..*" OrderItem : orderItems + OrderItem *-- Quantity : quantity + OrderItem *-- Money : price +``` + +### 봐야 할 포인트 + +1. **BR-O01 최소 항목 검증**: Order 생성자에서 `orderItems`가 비어있으면 예외를 발생시킨다. 빈 주문이 생성되는 것을 도메인 모델 수준에서 원천 차단한다. +2. **BR-O06 소유권 검증**: `isOwnedBy(userId)` 메서드로 주문의 소유자 여부를 판단한다. US-O03 시퀀스에서 Service가 이 메서드를 호출하여 타인의 주문 접근을 거부한다. +3. **OrderItem이 곧 스냅샷이다**: OrderItem이 `productName`, `brandName`, `price`를 직접 보유하여 주문 시점의 상품 정보를 보존한다 (BR-O05). OrderItem의 존재 이유 자체가 "주문 시점의 정보 보존"이므로, 별도 스냅샷 VO를 두지 않고 필드를 직접 갖는다. 원본 Product나 Brand가 이후 수정/삭제되어도 주문 기록에는 영향이 없다. +4. **Aggregate 경계**: Order가 Aggregate Root이고, OrderItem은 Order를 통해서만 접근한다. `Order.orderItems`는 `@OneToMany(cascade = ALL, orphanRemoval = true)`로 생명 주기를 함께 관리한다. +5. **OrderItem.orderId**: OrderItem이 BaseEntity를 상속하여 자체 id를 가진다. `orderId`는 DB 외래 키로 Order와 연결되지만, 도메인 모델에서는 Order가 `List`으로 직접 참조한다. +6. **Money 재사용**: `OrderItem.price`는 `Product.price`와 동일한 Money VO를 사용한다. VO 분리의 이점이 여기서 드러난다. + +--- + +## 잠재 리스크 + +### 책임 분산 점검표 + +| 엔티티 | 관심사 | 분산 방식 | 점검 | +|--------|--------|----------|------| +| Product | name | String (단순) | 검증만 필요, VO 불필요 | +| Product | price | Money VO | 음수 검증 위임 | +| Product | stock | Stock VO | 행위 3개(decrease, increase, hasEnough) 위임 | +| Product | likeCount | int (단순) | 증감만, VO 불필요 | +| CartItem | quantity | Quantity VO | >= 1 검증 + 수량 합산 위임 | +| OrderItem | quantity | Quantity VO | >= 1 검증 위임 | +| OrderItem | productName, brandName | String (단순) | 스냅샷 필드, OrderItem 자체가 스냅샷이므로 VO 불필요 | +| OrderItem | price | Money VO | Product.price와 동일 VO 재사용 | + +### VO 설계 리스크 + +| 리스크 | 설명 | 대응 | +|--------|------|------| +| **Stock 불변성과 JPA 매핑** | Stock VO가 불변이므로 `decrease()`가 새 인스턴스를 반환한다. JPA `@Embedded`로 매핑할 때 setter가 필요한지 확인이 필요하다 | `@Embedded` + `@Column`으로 매핑하되, JPA 접근용 protected 기본 생성자만 허용한다. 상태 변경은 `Product.decreaseStock()`이 새 Stock을 할당하는 방식으로 처리한다 | +| **Quantity 재사용 범위** | CartItem과 OrderItem에서 동일한 Quantity VO를 사용한다. 두 도메인의 수량 규칙이 달라질 가능성이 있다 | 현재는 동일한 규칙(>= 1)이므로 공유한다. 규칙이 분기되는 시점에 각 도메인 전용 VO로 분리한다 | +| **Money 확장 가능성** | 현재 `int amount`로 원화만 지원한다. 통화 단위가 추가되면 VO 구조가 변경된다 | 현재 범위에서는 원화 단일 통화로 충분하다. 다중 통화 요구가 확정되면 `currency` 필드를 추가한다 | + +### 도메인 간 정합성 리스크 + +| 리스크 | 관련 도메인 | 설명 | +|--------|------------|------| +| **좋아요 수 불일치** | Product ↔ Like | `Product.likeCount`와 실제 Like 레코드 수가 어긋날 수 있다. 트랜잭션 내 원자적 처리 + 배치 보정 전략이 필요하다 | +| ~~장바구니 상품 삭제~~ | ~~CartItem ↔ Product~~ | **해결됨**: 상품/브랜드 삭제 시 해당 장바구니 항목을 함께 물리 삭제한다. Like와 동일한 패턴 | +| **스냅샷 시점 정합성** | OrderItem ↔ Product | Facade에서 상품 정보를 조회한 시점과 Order를 저장하는 시점 사이에 상품 정보가 변경될 수 있다. 트랜잭션 격리 수준으로 방어한다 | +| **재고 동시성** | Product.stock ↔ Order | 동시 주문 시 재고가 음수가 될 수 있다. 비관적 잠금(SELECT FOR UPDATE) 또는 Stock VO의 `decrease()`에서 음수 검증으로 방어한다 | diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..7f056cfc9 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,337 @@ +# ERD + +## 공통 사항 + +### BaseEntity 컬럼 + +soft delete 정책 엔티티(brand, product, orders, order_item)는 아래 4개 컬럼을 공통으로 가진다. + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 고유 식별자 | +| created_at | DATETIME | NOT NULL | 생성 시각 (UTC) | +| updated_at | DATETIME | NOT NULL | 최종 수정 시각 (UTC) | +| deleted_at | DATETIME | nullable | soft delete 마커. NULL이면 활성 상태 | + +hard delete 정책 엔티티(likes, cart_item)는 `id`와 `created_at`만 자체 보유한다. + +### 컬럼 컨벤션 + +- 네이밍: snake_case (Spring Boot 기본 네이밍 전략) +- 타임존: UTC 저장 (`hibernate.timezone.default_storage: NORMALIZE_UTC`) +- FK 컬럼: `{참조 대상}_id` (예: `brand_id`, `user_id`) +- VO 매핑: `@Embedded`로 VO의 내부 값을 컬럼으로 펼친다 (예: Money.amount → `price`, Stock.quantity → `stock`) + +### FK 정책 + +- 본 ERD에서 "FK"로 표기된 컬럼은 **논리적 참조**를 의미한다. DB에 물리적 `FOREIGN KEY` 제약은 생성하지 않는다. +- 클래스 다이어그램에서 `@ManyToOne`이 아닌 `Long brandId`처럼 ID 값으로 느슨하게 참조하는 설계와 일치한다. +- 참조 무결성은 애플리케이션 레벨에서 보장한다: Facade가 존재 확인 → 저장 순서를 조율하고, 삭제 시 종속 데이터를 먼저 정리한다 (시퀀스 다이어그램 US-B06, US-P07 참고). + +--- + +## 전체 ERD + +### 검증 목적 + +모든 테이블의 관계, FK 방향, 카디널리티를 한눈에 파악한다. 클래스 다이어그램의 ID 참조가 실제 FK로 어떻게 표현되는지 확인한다. + +### 다이어그램 + +```mermaid +erDiagram + users { + bigint id PK + varchar login_id UK + varchar password + varchar name + date birthday + varchar email + datetime created_at + datetime updated_at + datetime deleted_at + } + + brand { + bigint id PK + varchar name + datetime created_at + datetime updated_at + datetime deleted_at + } + + product { + bigint id PK + bigint brand_id FK + varchar name + int price + int stock + int like_count + datetime created_at + datetime updated_at + datetime deleted_at + } + + likes { + bigint id PK + bigint user_id FK + bigint product_id FK + datetime created_at + } + + cart_item { + bigint id PK + bigint user_id FK + bigint product_id FK + int quantity + datetime created_at + } + + orders { + bigint id PK + bigint user_id FK + datetime created_at + datetime updated_at + datetime deleted_at + } + + order_item { + bigint id PK + bigint order_id FK + bigint product_id FK + int quantity + varchar product_name + varchar brand_name + int price + datetime created_at + datetime updated_at + datetime deleted_at + } + + users ||--o{ likes : "" + users ||--o{ cart_item : "" + users ||--o{ orders : "" + brand ||--o{ product : "" + product ||--o{ likes : "" + product ||--o{ cart_item : "" + product ||--o{ order_item : "" + orders ||--|{ order_item : "" +``` + +### 봐야 할 포인트 + +1. **users 테이블은 기존 구현**: 회원 도메인은 이미 구현되어 있으며 본 ERD에서는 FK 참조 대상으로만 포함한다. +2. **orders ↔ order_item**: 유일한 `||--|{` 관계(1:1이상). Order는 최소 1개의 OrderItem을 포함해야 한다 (BR-O01). 나머지는 모두 `||--o{`(1:0이상)이다. +3. **likes, cart_item에 deleted_at 없음**: hard delete 정책이므로 soft delete 컬럼이 불필요하다. +4. **order_item의 스냅샷 컬럼**: `product_name`, `brand_name`, `price`는 주문 시점의 상품 정보 사본이다. product, brand 테이블의 현재 값과 무관하게 주문 기록을 보존한다 (BR-O05). + +--- + +## 테이블 상세 + +### brand + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR(255) | NOT NULL | 브랜드명 | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | +| deleted_at | DATETIME | | soft delete 마커 | + +**유일성 제약**: +- `name`은 활성 상태(deleted_at IS NULL) 브랜드 내에서 유일해야 한다 (US-B04, US-B05) +- soft delete 테이블이므로 DB UNIQUE 제약만으로는 보장할 수 없다 → **애플리케이션 레벨에서 검증** (자세한 내용은 [데이터 정합성 전략 > 유일성 제약](#유일성-제약) 참고) + +--- + +### product + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| brand_id | BIGINT | NOT NULL, FK → brand(id) | 소속 브랜드. 생성 후 변경 불가 (BR-P02) | +| name | VARCHAR(255) | NOT NULL | 상품명 | +| price | INT | NOT NULL, >= 0 | 가격 (Money VO) | +| stock | INT | NOT NULL, >= 0 | 재고 수량 (Stock VO) | +| like_count | INT | NOT NULL, DEFAULT 0, >= 0 | 좋아요 수 | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | +| deleted_at | DATETIME | | soft delete 마커 | + +**유일성 제약**: +- `(brand_id, name)` 조합이 활성 상태 상품 내에서 유일해야 한다 (US-P05, US-P06) +- soft delete 테이블이므로 → **애플리케이션 레벨에서 검증** + +**인덱스**: +- `idx_product_brand_id` → `brand_id`: 브랜드별 상품 필터링 (US-P01, US-P03) + +--- + +### likes + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| user_id | BIGINT | NOT NULL, FK → users(id) | 좋아요 누른 회원 | +| product_id | BIGINT | NOT NULL, FK → product(id) | 좋아요 대상 상품 | +| created_at | DATETIME | NOT NULL | | + +**유일성 제약**: +- `uk_likes_user_product` → `UNIQUE(user_id, product_id)`: 한 회원은 동일 상품에 좋아요를 한 번만 등록 (BR-L01) +- hard delete 테이블이므로 **DB UNIQUE 제약으로 완전히 보장 가능**. 삭제 시 행이 물리적으로 제거되어 제약 슬롯이 해제된다 + +**인덱스**: +- `uk_likes_user_product`이 `(user_id, product_id)` 순서이므로, `user_id` 기준 조회(US-L03: 내 좋아요 목록)를 커버한다 + +--- + +### cart_item + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| user_id | BIGINT | NOT NULL, FK → users(id) | 장바구니 소유 회원 | +| product_id | BIGINT | NOT NULL, FK → product(id) | 담긴 상품 | +| quantity | INT | NOT NULL, >= 1 | 수량 (Quantity VO) | +| created_at | DATETIME | NOT NULL | | + +**유일성 제약**: +- `uk_cart_item_user_product` → `UNIQUE(user_id, product_id)`: 회원당 상품당 하나의 장바구니 항목만 존재. 같은 상품을 다시 담으면 기존 항목의 수량을 누적한다 (BR-C02) +- hard delete 테이블이므로 **DB UNIQUE 제약으로 완전히 보장 가능** + +**인덱스**: +- `uk_cart_item_user_product`이 `(user_id, product_id)` 순서이므로, `user_id` 기준 조회(US-C02: 내 장바구니)를 커버한다 + +--- + +### orders + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| user_id | BIGINT | NOT NULL, FK → users(id) | 주문한 회원 | +| created_at | DATETIME | NOT NULL | 주문 시각 | +| updated_at | DATETIME | NOT NULL | | +| deleted_at | DATETIME | | soft delete 마커 | + +**인덱스**: +- `idx_orders_user_id_created_at` → `(user_id, created_at)`: 회원의 기간별 주문 목록 조회 (US-O02, BR-O08) + +--- + +### order_item + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| order_id | BIGINT | NOT NULL, FK → orders(id) | 소속 주문 | +| product_id | BIGINT | NOT NULL, FK → product(id) | 주문 대상 상품 (원본 추적용) | +| quantity | INT | NOT NULL, >= 1 | 주문 수량 (Quantity VO) | +| product_name | VARCHAR(255) | NOT NULL | 주문 시점 상품명 (스냅샷) | +| brand_name | VARCHAR(255) | NOT NULL | 주문 시점 브랜드명 (스냅샷) | +| price | INT | NOT NULL, >= 0 | 주문 시점 가격 (스냅샷, Money VO) | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | +| deleted_at | DATETIME | | soft delete 마커 | + +**인덱스**: +- `idx_order_item_order_id` → `order_id`: 주문 상세 조회 시 주문 항목 일괄 로딩 (US-O03, US-O05) + +--- + +## 데이터 정합성 전략 + +### 참조 무결성 + +물리적 FK 제약 없이 애플리케이션 레벨에서 참조 무결성을 보장한다. 아래 표는 논리적 참조 관계와 그 정합성 보장 방식을 정리한 것이다. + +| 논리적 참조 | 목적 | 정합성 보장 방식 | +|------------|------|----------------| +| product.brand_id → brand(id) | 상품은 반드시 존재하는 브랜드에 속한다 (BR-P01) | Facade가 브랜드 존재 확인 후 상품 등록 (US-P05) | +| likes.user_id → users(id) | 좋아요는 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | +| likes.product_id → product(id) | 좋아요는 실존 상품만 가능 | Facade가 상품 존재 확인 후 좋아요 등록 (US-L01). 상품 삭제 시 likes를 먼저 hard delete (US-P07) | +| cart_item.user_id → users(id) | 장바구니는 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | +| cart_item.product_id → product(id) | 장바구니는 실존 상품만 가능 | Facade가 상품 존재 확인 후 담기 (US-C01). 상품 삭제 시 cart_item을 먼저 hard delete (US-P07) | +| orders.user_id → users(id) | 주문은 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | +| order_item.order_id → orders(id) | 주문 항목은 반드시 주문에 소속 | Order Aggregate가 OrderItem 생명주기를 관리 (cascade) | +| order_item.product_id → product(id) | 원본 상품 추적용 참조 | Facade가 상품 존재 및 재고 확인 후 주문 생성 (US-O01). 스냅샷 컬럼이 실제 데이터를 보존 | + +**삭제 시 FK 정합성 보장 순서:** + +상품/브랜드 삭제 시 Facade가 종속 데이터를 먼저 정리한다 (시퀀스 다이어그램 US-B06, US-P07 참고). + +``` +브랜드 삭제: likes(hard delete) → cart_item(hard delete) → product(soft delete) → brand(soft delete) +상품 삭제: likes(hard delete) → cart_item(hard delete) → product(soft delete) +``` + +종속 데이터(likes, cart_item)를 먼저 물리 삭제하므로, 상위 엔티티 soft delete 후에도 고아 FK가 남지 않는다. + +### 유일성 제약 + +삭제 정책에 따라 DB UNIQUE 제약의 적용 가능 여부가 달라진다. + +#### hard delete 테이블 → DB 레벨 UNIQUE 가능 + +| 테이블 | 제약 | 비즈니스 규칙 | +|--------|------|-------------| +| likes | `UNIQUE(user_id, product_id)` | BR-L01: 회원당 상품당 좋아요 1개 | +| cart_item | `UNIQUE(user_id, product_id)` | BR-C02: 회원당 상품당 장바구니 항목 1개 | + +행이 물리적으로 삭제되므로, 삭제 후 같은 조합으로 재등록이 가능하다. DB UNIQUE 제약이 완전하게 동작한다. + +#### soft delete 테이블 → 애플리케이션 레벨 검증 필요 + +| 테이블 | 유일 범위 | 비즈니스 규칙 | +|--------|----------|-------------| +| brand | `name` (활성 상태 내) | US-B04: 브랜드명 중복 불가 | +| product | `(brand_id, name)` (활성 상태 내) | US-P05: 같은 브랜드 내 상품명 중복 불가 | + +soft delete 테이블에서 단순 `UNIQUE(name)` 제약을 걸면 다음 문제가 발생한다: + +``` +1. 브랜드 "A" 등록 → name="A", deleted_at=NULL ← 정상 +2. 브랜드 "A" 삭제 (soft) → name="A", deleted_at=2024-... ← 행이 남아있음 +3. 브랜드 "A" 재등록 → name="A", deleted_at=NULL ← UNIQUE 위반! +``` + +삭제된 행이 물리적으로 남아있기 때문에, 같은 이름으로 재등록할 수 없게 된다. 따라서 Service 레벨에서 `WHERE deleted_at IS NULL AND name = ?` 조건으로 활성 데이터만 대상으로 중복을 검증한다. + +### 도메인 제약 + +VO의 검증 규칙이 DB 컬럼 제약으로도 방어된다. + +| 컬럼 | 제약 | 근거 | +|------|------|------| +| product.price | >= 0 | Money VO: 음수 불가 | +| product.stock | >= 0 | Stock VO: 음수 불가 | +| product.like_count | >= 0, DEFAULT 0 | 좋아요 수는 음수가 될 수 없다 | +| cart_item.quantity | >= 1 | Quantity VO: BR-C03 | +| order_item.quantity | >= 1 | Quantity VO: BR-O02 | +| order_item.price | >= 0 | Money VO: 음수 불가 | + +이중 방어 전략: VO 생성자에서 1차 검증 + DB CHECK 제약에서 2차 방어. 애플리케이션 버그로 잘못된 값이 전달되더라도 DB에서 최종 차단한다. + +### 인덱스 전략 + +시퀀스 다이어그램의 주요 조회 패턴에 맞춰 인덱스를 설계한다. + +| 인덱스 | 대상 조회 | 비고 | +|--------|----------|------| +| `idx_product_brand_id` | US-P01: 브랜드별 상품 필터링 | | +| `uk_likes_user_product` | US-L03: 내 좋아요 목록 | UNIQUE 제약이 인덱스 역할도 수행 | +| `uk_cart_item_user_product` | US-C02: 내 장바구니 조회 | UNIQUE 제약이 인덱스 역할도 수행 | +| `idx_orders_user_id_created_at` | US-O02: 기간별 주문 목록 | 복합 인덱스로 user_id 필터 + created_at 범위 검색을 커버 | +| `idx_order_item_order_id` | US-O03, O05: 주문 상세 | 주문 ID로 주문 항목 일괄 조회 | + +--- + +## 잠재 리스크 + +| 리스크 | 설명 | 대응 | +|--------|------|------| +| **soft delete 유일성 우회** | 애플리케이션 레벨 검증은 동시 요청 시 race condition이 발생할 수 있다 | 트랜잭션 격리 수준 또는 비관적 잠금으로 방어. 구현 시 결정 | +| **like_count 정합성** | Product.like_count와 likes 테이블의 실제 레코드 수가 어긋날 수 있다 | 트랜잭션 내 원자적 처리로 1차 방어. 필요 시 배치 보정으로 2차 방어 | +| **스냅샷 시점 정합성** | 주문 생성 중 상품 정보가 변경될 수 있다 | Facade 트랜잭션 내에서 상품 조회 → 주문 생성이 원자적으로 처리됨 | +| **재고 동시성** | 동시 주문 시 재고가 음수가 될 수 있다 | Stock >= 0 CHECK 제약이 DB 레벨 최종 방어선. 잠금 전략은 구현 시 결정 | From 988f22c62feb162d94cab9cf6e53eff8e629c401 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 16 Feb 2026 19:34:46 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[refactor]=20:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=9C,=20argumentResolver=EB=A5=BC=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9.=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20=ED=9B=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20@LoginUser=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=ED=86=B5=ED=95=B4=20UserInfo=EC=97=90=20?= =?UTF-8?q?=ED=95=A0=EB=8B=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 10 ++--- .../java/com/loopers/config/WebMvcConfig.java | 16 ++++++++ .../com/loopers/domain/user/UserService.java | 3 -- .../interfaces/api/support/LoginUser.java | 11 +++++ .../interfaces/api/user/UserV1ApiSpec.java | 5 ++- .../interfaces/api/user/UserV1Controller.java | 12 +++--- .../interceptor}/AuthInterceptor.java | 18 +++++---- .../resolver/LoginUserArgumentResolver.java | 40 +++++++++++++++++++ .../loopers/domain/user/UserServiceTest.java | 2 +- .../interceptor}/AuthInterceptorTest.java | 22 ++++++---- 10 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/LoginUser.java rename apps/commerce-api/src/main/java/com/loopers/{config => interfaces/interceptor}/AuthInterceptor.java (75%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java rename apps/commerce-api/src/test/java/com/loopers/{config => interfaces/interceptor}/AuthInterceptorTest.java (82%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index a738467b1..68acab0db 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -10,6 +10,11 @@ public class UserFacade { private final UserService userService; + public UserInfo authenticate(String loginId, String password) { + UserModel user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + public UserInfo signUp(SignupCommand command) { UserModel user = userService.signup( command.loginId(), command.password(), command.name(), command.birthday(), command.email() @@ -17,11 +22,6 @@ public UserInfo signUp(SignupCommand command) { return UserInfo.from(user); } - public UserInfo getMyInfo(String loginId) { - UserModel user = userService.findByLoginId(loginId); - return UserInfo.from(user); - } - public void changePassword(ChangePasswordCommand command) { userService.changePassword(command.loginId(), command.currentPassword(), command.newPassword()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 1a59990eb..289e7e179 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -1,20 +1,36 @@ package com.loopers.config; +import com.loopers.interfaces.interceptor.AdminAuthInterceptor; +import com.loopers.interfaces.interceptor.AuthInterceptor; +import com.loopers.interfaces.resolver.LoginUserArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @RequiredArgsConstructor @Configuration public class WebMvcConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; + private final LoginUserArgumentResolver loginUserArgumentResolver; + private final AdminAuthInterceptor adminAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/v1/users/signup"); + + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/**"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginUserArgumentResolver); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 89e594b2c..bae3e5a88 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -3,12 +3,9 @@ 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.Transactional; -import java.util.Optional; - @RequiredArgsConstructor @Component public class UserService { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/LoginUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/LoginUser.java new file mode 100644 index 000000000..0e07b48ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/LoginUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 6c32ff1c5..10bb67a7b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -17,11 +18,11 @@ public interface UserV1ApiSpec { summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다." ) - ApiResponse getMe(String loginId); + ApiResponse getMe(UserInfo userInfo); @Operation( summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다." ) - ApiResponse changePassword(String loginId, UserV1Dto.ChangePasswordRequest request); + ApiResponse changePassword(UserInfo userInfo, UserV1Dto.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index b842d3f2b..82583d165 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,13 +2,12 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; -import com.loopers.config.AuthInterceptor; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.LoginUser; import lombok.RequiredArgsConstructor; 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.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -33,20 +32,19 @@ public ApiResponse signup( @GetMapping("/me") @Override public ApiResponse getMe( - @RequestAttribute(AuthInterceptor.ATTR_LOGIN_ID) String loginId + @LoginUser UserInfo userInfo ) { - UserInfo info = userFacade.getMyInfo(loginId); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); return ApiResponse.success(response); } @PatchMapping("/password") @Override public ApiResponse changePassword( - @RequestAttribute(AuthInterceptor.ATTR_LOGIN_ID) String loginId, + @LoginUser UserInfo userInfo, @RequestBody UserV1Dto.ChangePasswordRequest request ) { - userFacade.changePassword(request.toCommand(loginId)); + userFacade.changePassword(request.toCommand(userInfo.loginId())); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java similarity index 75% rename from apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java index 8cec0fc83..b3cf15943 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AuthInterceptor.java @@ -1,6 +1,8 @@ -package com.loopers.config; +package com.loopers.interfaces.interceptor; -import com.loopers.domain.user.UserService; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.resolver.LoginUserArgumentResolver; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; @@ -19,9 +21,8 @@ public class AuthInterceptor implements HandlerInterceptor { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - public static final String ATTR_LOGIN_ID = "loginId"; - private final UserService userService; + private final UserFacade userFacade; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { @@ -33,13 +34,14 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); } - try{ - userService.authenticate(loginId, loginPw); - }catch(CoreException e){ + UserInfo userInfo; + try { + userInfo = userFacade.authenticate(loginId, loginPw); + } catch (CoreException e) { log.warn("인증 실패 - loginId: {}, URI: {}", loginId, request.getRequestURI()); throw e; } - request.setAttribute(ATTR_LOGIN_ID, loginId); + request.setAttribute(LoginUserArgumentResolver.ATTR_LOGIN_USER, userInfo); return true; } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java new file mode 100644 index 000000000..9397e4dac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.resolver; + +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.support.LoginUser; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String ATTR_LOGIN_USER = "loginUser"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginUser.class) + && parameter.getParameterType().equals(UserInfo.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Object userInfo = request.getAttribute(ATTR_LOGIN_USER); + + if (userInfo == null) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증된 사용자 정보가 존재하지 않습니다."); + } + + return userInfo; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 1f8f0798b..3a0cc02c2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -9,7 +9,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; +import com.loopers.domain.user.PasswordEncoder; import java.util.Optional; diff --git a/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/interceptor/AuthInterceptorTest.java similarity index 82% rename from apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/interceptor/AuthInterceptorTest.java index 24493123f..5d3d8c986 100644 --- a/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/interceptor/AuthInterceptorTest.java @@ -1,6 +1,8 @@ -package com.loopers.config; +package com.loopers.interfaces.interceptor; -import com.loopers.domain.user.UserService; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.resolver.LoginUserArgumentResolver; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; @@ -13,6 +15,8 @@ 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.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; @@ -24,9 +28,10 @@ class AuthInterceptorTest { private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; private static final String VALID_LOGIN_ID = "namjin123"; private static final String VALID_PASSWORD = "qwer@1234"; + private static final UserInfo VALID_USER_INFO = new UserInfo(1L, VALID_LOGIN_ID, "테스트", LocalDate.of(2000, 1, 1), "test@test.com"); @Mock - private UserService userService; + private UserFacade userFacade; @InjectMocks private AuthInterceptor authInterceptor; @@ -44,14 +49,15 @@ void returnsTrue_whenCredentialsAreValid() { when(request.getHeader(HEADER_LOGIN_ID)).thenReturn(VALID_LOGIN_ID); when(request.getHeader(HEADER_LOGIN_PW)).thenReturn(VALID_PASSWORD); + when(userFacade.authenticate(VALID_LOGIN_ID, VALID_PASSWORD)).thenReturn(VALID_USER_INFO); // act boolean result = authInterceptor.preHandle(request, response, new Object()); // assert assertThat(result).isTrue(); - verify(userService).authenticate(VALID_LOGIN_ID, VALID_PASSWORD); - verify(request).setAttribute(AuthInterceptor.ATTR_LOGIN_ID, VALID_LOGIN_ID); + verify(userFacade).authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + verify(request).setAttribute(LoginUserArgumentResolver.ATTR_LOGIN_USER, VALID_USER_INFO); } @DisplayName("인증 헤더가 누락되면, 예외가 발생한다.") @@ -70,7 +76,7 @@ void throwsException_whenHeadersMissing() { }); assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - verify(userService, never()).authenticate(any(), any()); + verify(userFacade, never()).authenticate(any(), any()); } @DisplayName("인증에 실패하면, 예외가 전파된다.") @@ -84,7 +90,7 @@ void throwsException_whenAuthenticationFails() { when(request.getHeader(HEADER_LOGIN_PW)).thenReturn("wrongPassword"); when(request.getRequestURI()).thenReturn("/api/v1/users/me"); - when(userService.authenticate(VALID_LOGIN_ID, "wrongPassword")) + when(userFacade.authenticate(VALID_LOGIN_ID, "wrongPassword")) .thenThrow(new CoreException(ErrorType.UNAUTHORIZED, "회원 정보가 올바르지 않습니다.")); // act & assert @@ -111,7 +117,7 @@ void throwsException_whenHeadersAreBlank() { }); assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - verify(userService, never()).authenticate(any(), any()); + verify(userFacade, never()).authenticate(any(), any()); } } } From c06a7d45723b595551105192f4fb8aee5eca96b3 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 16 Feb 2026 19:35:34 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[feat]=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20AdminInterceptor=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interceptor/AdminAuthInterceptor.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AdminAuthInterceptor.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AdminAuthInterceptor.java new file mode 100644 index 000000000..e2e3a9de2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/interceptor/AdminAuthInterceptor.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.interceptor; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + private static final Logger log = LoggerFactory.getLogger(AdminAuthInterceptor.class); + + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ + String adminString = request.getHeader(ADMIN_HEADER); + + if(adminString == null || adminString.isBlank()){ + log.warn("관리자 인증 헤더 누락 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 누락되었습니다."); + } + + if(!adminString.equals(ADMIN_LDAP_VALUE)){ + log.warn("관리자 인증 실패 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증에 실패했습니다."); + } + return true; + } + +} From 84e9793ad0ab2afda2d0588d1be926397395e7f5 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 16 Feb 2026 19:40:50 +0900 Subject: [PATCH 05/18] =?UTF-8?q?[refactor]=20:=20PasswordEncoder=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20Spring=20Security=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 레이어에 PasswordEncoder 인터페이스 정의 - BcryptPasswordEncoder 구현체를 Infrastructure 레이어로 이동 - 도메인이 외부 프레임워크(Spring Security)에 의존하지 않도록 개선 --- .../com/loopers/config/SecurityConfig.java | 15 ------------- .../loopers/domain/user/PasswordEncoder.java | 6 ++++++ .../user/BcryptPasswordEncoder.java | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 15 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java deleted file mode 100644 index 52b04d232..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.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 SecurityConfig { - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java new file mode 100644 index 000000000..bb23c0b39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.loopers.domain.user; + +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java new file mode 100644 index 000000000..c164209ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BcryptPasswordEncoder implements PasswordEncoder { + + private final BCryptPasswordEncoder delegate = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} From fb95ce0ec245e856b3e931f36aae06712015d9f9 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 16 Feb 2026 19:43:43 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[docs]=20:=20claude.md=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 모델링 및 아키텍처 레이어별 역할 명시 --- CLAUDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d2b946e85..8f6bb6da5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,3 +142,19 @@ Entity → ExampleInfo (application DTO) → ExampleV1Dto (API DTO) → ApiRespo | `REDIS_MASTER_HOST`, `REDIS_MASTER_PORT` | Redis Master | | `REDIS_REPLICA_1_HOST`, `REDIS_REPLICA_1_PORT` | Redis Replica | | `BOOTSTRAP_SERVERS` | Kafka 브로커 | + +## 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. + +## 아키텍처, 패키지 구성 전략 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성하도록 합니다. +- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태로 작성합니다. + - 예시 + > /interfaces/api (presentation 레이어 - API) + /application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공) + /domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치) + /infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공) From 8d6e9abde32877e12a96434b13ccf66df193a2b9 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 16 Feb 2026 19:52:04 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[docs]=20:=20erd=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/04-erd.md | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 7f056cfc9..a950a29a3 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -1,33 +1,5 @@ # ERD -## 공통 사항 - -### BaseEntity 컬럼 - -soft delete 정책 엔티티(brand, product, orders, order_item)는 아래 4개 컬럼을 공통으로 가진다. - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 고유 식별자 | -| created_at | DATETIME | NOT NULL | 생성 시각 (UTC) | -| updated_at | DATETIME | NOT NULL | 최종 수정 시각 (UTC) | -| deleted_at | DATETIME | nullable | soft delete 마커. NULL이면 활성 상태 | - -hard delete 정책 엔티티(likes, cart_item)는 `id`와 `created_at`만 자체 보유한다. - -### 컬럼 컨벤션 - -- 네이밍: snake_case (Spring Boot 기본 네이밍 전략) -- 타임존: UTC 저장 (`hibernate.timezone.default_storage: NORMALIZE_UTC`) -- FK 컬럼: `{참조 대상}_id` (예: `brand_id`, `user_id`) -- VO 매핑: `@Embedded`로 VO의 내부 값을 컬럼으로 펼친다 (예: Money.amount → `price`, Stock.quantity → `stock`) - -### FK 정책 - -- 본 ERD에서 "FK"로 표기된 컬럼은 **논리적 참조**를 의미한다. DB에 물리적 `FOREIGN KEY` 제약은 생성하지 않는다. -- 클래스 다이어그램에서 `@ManyToOne`이 아닌 `Long brandId`처럼 ID 값으로 느슨하게 참조하는 설계와 일치한다. -- 참조 무결성은 애플리케이션 레벨에서 보장한다: Facade가 존재 확인 → 저장 순서를 조율하고, 삭제 시 종속 데이터를 먼저 정리한다 (시퀀스 다이어그램 US-B06, US-P07 참고). - --- ## 전체 ERD From ceb53acfc1c5924b328fddaa11446ddd9524cdc3 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 23 Feb 2026 18:25:27 +0900 Subject: [PATCH 08/18] =?UTF-8?q?[docs]=20:=20claude.md=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8f6bb6da5..7bff1b1e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,3 +158,19 @@ Entity → ExampleInfo (application DTO) → ExampleV1Dto (API DTO) → ApiRespo /application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공) /domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치) /infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공) + +## 개발 규칙 + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 \ No newline at end of file From 21fb05840f1d8f003f9f97c5ff4620b9aa1ab691 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Mon, 23 Feb 2026 18:40:32 +0900 Subject: [PATCH 09/18] =?UTF-8?q?[feat]=20:=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandAdminFacade.java | 42 +++ .../application/brand/BrandFacade.java | 19 ++ .../loopers/application/brand/BrandInfo.java | 21 ++ .../brand/BrandRegisterCommand.java | 5 + .../application/brand/BrandUpdateCommand.java | 6 + .../java/com/loopers/domain/brand/Brand.java | 37 ++ .../loopers/domain/brand/BrandRepository.java | 14 + .../loopers/domain/brand/BrandService.java | 59 ++++ .../brand/BrandJpaRepository.java | 9 + .../brand/BrandRepositoryImpl.java | 42 +++ .../api/brand/BrandAdminV1ApiSpec.java | 53 +++ .../api/brand/BrandAdminV1Controller.java | 66 ++++ .../interfaces/api/brand/BrandAdminV1Dto.java | 65 ++++ .../interfaces/api/brand/BrandV1ApiSpec.java | 18 + .../api/brand/BrandV1Controller.java | 26 ++ .../interfaces/api/brand/BrandV1Dto.java | 15 + .../brand/BrandServiceIntegrationTest.java | 179 ++++++++++ .../domain/brand/BrandServiceTest.java | 208 ++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 103 ++++++ .../api/BrandAdminV1ApiE2ETest.java | 320 ++++++++++++++++++ .../interfaces/api/BrandV1ApiE2ETest.java | 125 +++++++ 21 files changed, 1432 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.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/application/brand/BrandRegisterCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandUpdateCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/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/domain/brand/BrandServiceIntegrationTest.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/BrandAdminV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java new file mode 100644 index 000000000..194b36d2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandAdminFacade { + private final BrandService brandService; + + // 브랜드 등록 + public BrandInfo register(String name){ + Brand brand = brandService.register(name); + return BrandInfo.from(brand); + } + + // 브랜드 상세 조회 + public BrandInfo findById(Long id){ + Brand brand = brandService.findById(id); + return BrandInfo.from(brand); + } + + // 브랜드 목록 조회 + public Page findAll(Pageable pageable){ + return brandService.findAll(pageable).map(BrandInfo::from); + } + + // 브랜드 정보 수정 + public BrandInfo update(BrandUpdateCommand command){ + Brand brand = brandService.update(command.id(), command.name()); + return BrandInfo.from(brand); + } + + // 브랜드 삭제 + public void delete(Long id){ + brandService.delete(id); + } +} 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..9c2f329ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,19 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + private final BrandService brandService; + + // 브랜드 상세 조회 + public BrandInfo findById(Long id){ + Brand brand = brandService.findById(id); + return BrandInfo.from(brand); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..ec1980c0e --- /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; + +import java.time.ZonedDateTime; + +public record BrandInfo( + Long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static BrandInfo from(Brand brand){ + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRegisterCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRegisterCommand.java new file mode 100644 index 000000000..01c69ccf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandRegisterCommand.java @@ -0,0 +1,5 @@ +package com.loopers.application.brand; + +public record BrandRegisterCommand( + String name +){} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandUpdateCommand.java new file mode 100644 index 000000000..7885fbe06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandUpdateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.brand; + +public record BrandUpdateCommand( + Long id, + String name +) {} 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..67869fab5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,37 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "brand") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Brand extends BaseEntity { + + @Column(nullable = false) + private String name; + + public Brand(String name) { + validateName(name); + this.name = name; + } + + public void update(String name) { + validateName(name); + this.name = name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..57db0ab28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + Page findAll(Pageable pageable); + boolean existsByName(String name); + boolean existsByNameAndIdNot(String name, Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..570f175fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,59 @@ +package com.loopers.domain.brand; + +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.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandService { + private final BrandRepository brandRepository; + + // 브랜드 등록 + @Transactional + public Brand register(String name) { + if (brandRepository.existsByName(name)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드명입니다."); + } + Brand brand = new Brand(name); + return brandRepository.save(brand); + } + + // 브랜드 상세 조회 + @Transactional(readOnly = true) + public Brand findById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + } + + // 브랜드 목록 조회 + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + // 브랜드 정보 수정 + @Transactional + public Brand update(Long id, String name) { + Brand brand = findById(id); + if (brandRepository.existsByNameAndIdNot(name, id)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드명입니다."); + } + // DB에서 brand 정보를 불러왔기 때문에, 이미 영속 상태이므로 따로 save()를 호출하지 않아도 된다. + brand.update(name); + return brand; + } + + // 브랜드 삭제 + @Transactional + public void delete(Long id) { + Brand brand = findById(id); + brand.delete(); + } +} 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..45b7957a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + boolean existsByNameAndDeletedAtIsNull(String name); + boolean existsByNameAndIdNotAndDeletedAtIsNull(String name, Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..abbc1d4ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + public Optional findById(Long id){ + return brandJpaRepository.findById(id); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAll(pageable); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByNameAndDeletedAtIsNull(name); + } + + @Override + public boolean existsByNameAndIdNot(String name, Long id) { + return brandJpaRepository.existsByNameAndIdNotAndDeletedAtIsNull(name, id); + } + + public Brand save(Brand brand){ + return brandJpaRepository.save(brand); + } + +} 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..c6f2bbfde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,53 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "BrandAdmin V1 API", description = "관리자용 브랜드 관련 API 입니다.") +public interface BrandAdminV1ApiSpec { + + @Operation( + summary = "브랜드 등록", + description = "관리자가 새로운 브랜드를 등록합니다." + ) + ApiResponse registerBrand(BrandAdminV1Dto.RegisterRequest request); + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드의 상세 정보를 조회합니다." + ) + ApiResponse getBrandDetails( + @Parameter(description = "브랜드 ID") long id + ); + + @Operation( + summary = "브랜드 목록 조회", + description = "등록되어 있는 브랜드 목록을 조회합니다." + ) + ApiResponse getBrandList( + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 당 나타낼 데이터 개수", example = "10") + @RequestParam(defaultValue = "10") int size + ); + + @Operation( + summary = "브랜드 정보 수정", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse updateBrand( + @Parameter(description = "브랜드 ID") long id, + BrandAdminV1Dto.UpdateRequest request + ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다." + ) + ApiResponse deleteBrand( + @Parameter(description = "브랜드 ID") long id + ); +} 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..88eb7366d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandAdminFacade; +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.PageRequest; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandAdminFacade brandAdminFacade; + + @PostMapping + @Override + public ApiResponse registerBrand( + @RequestBody BrandAdminV1Dto.RegisterRequest request) { + BrandInfo info = brandAdminFacade.register(request.name()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getBrandDetails( + @PathVariable long id) { + BrandInfo info = brandAdminFacade.findById(id); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getBrandList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page infoPage = brandAdminFacade.findAll(PageRequest.of(page, size)); + return ApiResponse.success(BrandAdminV1Dto.BrandListResponse.from(infoPage)); + } + + @PutMapping("/{id}") + @Override + public ApiResponse updateBrand( + @PathVariable long id, + @RequestBody BrandAdminV1Dto.UpdateRequest request) { + BrandInfo info = brandAdminFacade.update(request.toCommand(id)); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{id}") + @Override + public ApiResponse deleteBrand(@PathVariable long id) { + brandAdminFacade.delete(id); + return ApiResponse.success(null); + } +} 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..651378650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandRegisterCommand; +import com.loopers.application.brand.BrandUpdateCommand; +import org.springframework.data.domain.Page; + +import java.time.ZonedDateTime; +import java.util.List; + +public class BrandAdminV1Dto { + + // 브랜드 등록 요청 객체 + public record RegisterRequest( + String name + ){ + public BrandRegisterCommand toCommand(){ + return new BrandRegisterCommand(name); + } + } + + // 브랜드 정보 수정 요청 객체 + public record UpdateRequest( + String name + ){ + public BrandUpdateCommand toCommand(long id){ + return new BrandUpdateCommand(id, name); + } + } + + public record BrandResponse( + long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ){ + public static BrandResponse from(BrandInfo info){ + return new BrandResponse( + info.id(), + info.name(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record BrandListResponse( + List brands, + int page, + int size, + long totalElements, + int totalPages + ){ + public static BrandListResponse from(Page info){ + return new BrandListResponse( + info.getContent().stream().map(BrandResponse::from).toList(), + info.getNumber(), + info.getSize(), + info.getTotalElements(), + info.getTotalPages() + ); + } + } + +} 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..51da17292 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,18 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "고객용 브랜드 관련 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 상세 조회", + description = "브랜드의 상세 정보를 조회합니다." + ) + ApiResponse getBrandDetails( + @Parameter(description = "브랜드 ID") long id + ); +} 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..d102524de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,26 @@ +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{id}") + @Override + public ApiResponse getBrandDetails( + @PathVariable long id) { + BrandInfo info = brandFacade.findById(id); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..2c44087f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + long id, + String name + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..fafdf2172 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,179 @@ +package com.loopers.domain.brand; + +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +public class BrandServiceIntegrationTest { + private static final String VALID_BRAND_NAME = "아디다스"; + private static final Long NOT_EXISTED_BRAND_ID = 999L; + private static final String NEW_BRAND_NAME = "퓨마"; + + @Autowired + private BrandService brandService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown(){ + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드를 등록 시") + @Nested + class Register{ + @DisplayName("중복되지 않은 브랜드명으로 등록에 성공한다.") + @Test + void registersBrandSucceed_whenBrandNameIsUnique(){ + // act + Brand result = brandService.register(VALID_BRAND_NAME); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo(VALID_BRAND_NAME); + + // DB에 실제로 저장했는지 확인 + Brand saved = brandJpaRepository.findById(result.getId()).orElseThrow(); + assertThat(saved.getName()).isEqualTo(VALID_BRAND_NAME); + } + + @DisplayName("중복되는 브랜드명으로 등록에 실패한다.") + @Test + void registersBrandFail_whenBrandNameIsNotUnique(){ + // arrange + Brand existingBrand = new Brand(VALID_BRAND_NAME); + brandJpaRepository.save(existingBrand); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.register(VALID_BRAND_NAME); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 상세 조회 시") + @Nested + class FindById{ + @DisplayName("존재하는 brandId로 조회하면, 상세 정보를 반환한다") + @Test + void findByIdSucceed_whenBrandIdIsExists(){ + // arrange + Brand existingBrand = new Brand(VALID_BRAND_NAME); + existingBrand = brandJpaRepository.save(existingBrand); + + // act + Brand brand = brandService.findById(existingBrand.getId()); + + // assert + assertThat(brand.getName()).isEqualTo(existingBrand.getName()); + } + + @DisplayName("존재하지 않는 brandId로 조회하면, 에러를 반환한다.") + @Test + void findByIdFail_whenBrandIdIsNotExists(){ + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(NOT_EXISTED_BRAND_ID); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 정보 수정 시") + @Nested + class Update{ + @DisplayName("중복되지 않는 브랜드명으로 수정 시, 성공한다.") + @Test + void updateSucceed_whenBrandNameIsUnique(){ + // arrange + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + + // act + Brand result = brandService.update(existingBrand.getId(), NEW_BRAND_NAME); + + // assert + assertThat(result.getName()).isEqualTo(NEW_BRAND_NAME); + // dirty checking으로 인해 실제 DB에 반영됐는지도 확인 + Brand updated = brandJpaRepository.findById(existingBrand.getId()).orElseThrow(); + assertThat(updated.getName()).isEqualTo(NEW_BRAND_NAME); + } + + @DisplayName("중복되는 브랜드명으로 수정 시, conflict 에러를 반환한다.") + @Test + void updateFailed_whenBrandNameIsNotUnique(){ + // arrange + Brand existingBrand1 = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + Brand existingBrand2 = brandJpaRepository.save(new Brand(NEW_BRAND_NAME)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(existingBrand1.getId(), existingBrand2.getName()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("존재하지 않는 brandId로 수정 시, 404에러를 반환한다.") + @Test + void updateFailed_whenBrandIdIsNotExists(){ + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(NOT_EXISTED_BRAND_ID, VALID_BRAND_NAME); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시") + @Nested + class Delete{ + @DisplayName("존재하는 brandId로 요청하면, 정상진행") + @Test + void deleteSucceed_whenBrandIdIsValid(){ + // arrange + Brand brand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + + // act + brandService.delete(brand.getId()); + + // assert + Brand deleted = brandJpaRepository.findById(brand.getId()).orElseThrow(); + assertThat(deleted.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 brandId로 요청하면, 404에러 발생") + @Test + void deleteFailed_whenBrandIdIsNotExists(){ + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.delete(NOT_EXISTED_BRAND_ID); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} 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..bf810ef97 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,208 @@ +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + private static final String VALID_BRAND_NAME = "아디다스"; + private static final Long VALID_BRAND_ID = 1L; + private static final String NOT_UNIQUE_BRAND_NAME = "나이키"; + private static final Long NOT_EXISTED_BRAND_ID = 999L; + + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private BrandService brandService; + + @DisplayName("브랜드 등록 시") + @Nested + class Register{ + + @DisplayName("중복되지 않은 브랜드명으로 등록에 성공한다.") + @Test + void registersBrandSucceed_whenBrandNameIsUnique(){ + // arrange + // stub: existsByName 호출 시 , false 반환 + when(brandRepository.existsByName(VALID_BRAND_NAME)).thenReturn(false); + + // stub: save 메서드 호출 시 저장된 객체 반환 + when(brandRepository.save(any(Brand.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + Brand result = brandService.register(VALID_BRAND_NAME); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo(VALID_BRAND_NAME); + } + + @DisplayName("중복되는 브랜드명으로 등록에 실패한다.") + @Test + void registersBrandFail_whenBrandNameIsNotUnique(){ + // stub : existsByName 호출 시 true 반환 + when(brandRepository.existsByName("나이키")).thenReturn(true); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.register(NOT_UNIQUE_BRAND_NAME); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + + // 행위 검증 + verify(brandRepository, never()).save(any()); + } + } + + @DisplayName("브랜드 상세 조회 시") + @Nested + class FindById{ + + @DisplayName("존재하는 brandId로 조회하면, 상세 정보를 반환한다") + @Test + void findByIdSucceed_whenBrandIdIsExsits(){ + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + // stub + when(brandRepository.findById(VALID_BRAND_ID)).thenReturn(Optional.of(brand)); + + // act + Brand result = brandService.findById(VALID_BRAND_ID); + + // assert + assertThat(result.getName()).isEqualTo(VALID_BRAND_NAME); + } + + @DisplayName("존재하지 않는 brandId로 조회하면, 에러를 반환한다.") + @Test + void findByIdFail_whenBrandIdIsNotExists(){ + // stub + when(brandRepository.findById(NOT_EXISTED_BRAND_ID)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.findById(999L); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 정보 수정 시") + @Nested + class Update{ + + @DisplayName("중복되지 않는 브랜드명으로 수정 시, 성공한다.") + @Test + void updateSucceed_whenBrandNameIsUnique(){ + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + String newName = "새로운 나이키"; + + // stub + when(brandRepository.findById(VALID_BRAND_ID)).thenReturn(Optional.of(brand)); + when(brandRepository.existsByNameAndIdNot(newName, VALID_BRAND_ID)).thenReturn(false); + + // act + Brand result = brandService.update(VALID_BRAND_ID, newName); + + // assert + assertThat(result.getName()).isEqualTo(newName); + } + + @DisplayName("중복되는 브랜드명으로 수정 시, conflict 에러를 반환한다.") + @Test + void updateFailed_whenBrandNameIsNotUnique(){ + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + + // stub + when(brandRepository.findById(VALID_BRAND_ID)).thenReturn(Optional.of(brand)); + + when(brandRepository.existsByNameAndIdNot(VALID_BRAND_NAME, VALID_BRAND_ID)).thenReturn(true); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(VALID_BRAND_ID, VALID_BRAND_NAME); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("존재하지 않는 brandId로 수정 시, 404에러를 반환한다.") + @Test + void updateFailed_whenBrandIdIsNotExists(){ + // stub + when(brandRepository.findById(NOT_EXISTED_BRAND_ID)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(NOT_EXISTED_BRAND_ID, VALID_BRAND_NAME); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + } + + @DisplayName("브랜드 삭제 시") + @Nested + class Delete{ + + @DisplayName("존재하는 brandId로 요청하면, 정상진행") + @Test + void deleteSucceed_whenBrandIdIsValid(){ + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + + // stub + when(brandRepository.findById(VALID_BRAND_ID)).thenReturn(Optional.of(brand)); + + // act + brandService.delete(VALID_BRAND_ID); + + // assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 brandId로 요청하면, 404에러 발생") + @Test + void deleteFailed_whenBrandIdIsNotExists(){ + // stub + when(brandRepository.findById(NOT_EXISTED_BRAND_ID)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.delete(NOT_EXISTED_BRAND_ID); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + + } + } +} 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..0bcbe47a4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,103 @@ +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + private static final String VALID_BRAND_NAME = "아디다스"; + + @DisplayName("브랜드를 생성할 때") + @Nested + class Create { + + @DisplayName("모든 정보가 올바르면, 브랜드가 정상적으로 생성된다.") + @Test + void createsBrand_whenAllInfoIsValid() { + // act + Brand brand = new Brand(VALID_BRAND_NAME); + + // assert + assertThat(brand.getName()).isEqualTo(VALID_BRAND_NAME); + } + + @DisplayName("브랜드명이 null이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Brand(null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드명이 비어있으면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Brand(" "); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드 정보를 수정할 때") + @Nested + class Update { + + @DisplayName("올바른 브랜드명으로 수정하면, 브랜드명이 변경된다.") + @Test + void updatesBrandName_whenNewNameIsValid() { + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + String newName = "나이키"; + + // act + brand.update(newName); + + // assert + assertThat(brand.getName()).isEqualTo(newName); + } + + @DisplayName("브랜드명이 null이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewNameIsNull() { + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update(null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드명이 비어있으면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewNameIsBlank() { + // arrange + Brand brand = new Brand(VALID_BRAND_NAME); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update(" "); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java new file mode 100644 index 000000000..270428cc1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java @@ -0,0 +1,320 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +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.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.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandAdminV1ApiE2ETest { + + private static final String VALID_BRAND_NAME = "아디다스"; + private static final String NEW_BRAND_NAME = "나이키"; + private static final String ENDPOINT_POST = "/api-admin/v1/brands"; + private static final String ENDPOINT_GET_LIST = "/api-admin/v1/brands"; + private static final Function ENDPOINT_GET = id -> "/api-admin/v1/brands/" + id; + private static final Long NOT_EXISTED_BRAND_ID = 999L; + + private static final String HEADER_ADMIN_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + + @Autowired + public BrandAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + HttpHeaders createAdminHeaders(){ + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); + return headers; + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Register { + @DisplayName("정상적인 브랜드명으로 등록하면, 200 OK와 브랜드 정보를 반환한다.") + @Test + void returnBrandInfo_whenRegisterIsValid() { + // arrange + // 헤더에 관리자 인증 정보 세팅 + HttpHeaders headers = createAdminHeaders(); + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(VALID_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isPositive(), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_BRAND_NAME) + ); + } + + @DisplayName("관리자 인증 헤더가 누락되면, 401 UNAUTHORIZED 응답을 받는다") + @Test + void returnsUnauthorized_whenHeadersMissing(){ + // arrange + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(VALID_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.UNAUTHORIZED.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("중복되는 브랜드명으로 등록하면, 409 에러를 반환한다") + @Test + void returnsConflict_whenBrandNameIsNotUnique(){ + // arrange + brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + + HttpHeaders headers = createAdminHeaders(); + BrandAdminV1Dto.RegisterRequest request = new BrandAdminV1Dto.RegisterRequest(VALID_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.CONFLICT.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands/{id}") + @Nested + class GetBrandDetails { + @DisplayName("존재하는 brandId로 요청하면, 200 OK와 브랜드 상세 정보를 반환한다.") + @Test + void returnsBrandDetails_whenGivenBrandIdIsValid() { + // arrange + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + String requestUrl = ENDPOINT_GET.apply(existingBrand.getId()); + HttpHeaders headers = createAdminHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isEqualTo(existingBrand.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(existingBrand.getName()) + ); + } + + @DisplayName("존재하지 않는 brandId로 요청하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenBrandIdDoesNotExist() { + // arrange + String requestUrl = ENDPOINT_GET.apply(NOT_EXISTED_BRAND_ID); + HttpHeaders headers = createAdminHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class GetBrandList { + @DisplayName("등록된 브랜드가 있을 때 목록을 조회하면, 200 OK와 브랜드 목록을 반환한다.") + @Test + void returnsBrandList_whenBrandsExist() { + // arrange + brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + HttpHeaders headers = createAdminHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET_LIST, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().brands().get(0).name()).isEqualTo(VALID_BRAND_NAME) + ); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{id}") + @Nested + class UpdateBrand { + @DisplayName("중복되지 않는 브랜드명으로 수정하면, 200 OK와 수정된 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenUpdateIsValid() { + // arrange + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + HttpHeaders headers = createAdminHeaders(); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest(NEW_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_GET.apply(existingBrand.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().name()).isEqualTo(NEW_BRAND_NAME) + ); + } + + @DisplayName("중복되는 브랜드명으로 수정하면, 409 CONFLICT 응답을 받는다.") + @Test + void returnsConflict_whenBrandNameIsNotUnique() { + // arrange + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + brandJpaRepository.save(new Brand(NEW_BRAND_NAME)); + HttpHeaders headers = createAdminHeaders(); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest(NEW_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_GET.apply(existingBrand.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.CONFLICT.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 brandId로 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenBrandIdDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest(NEW_BRAND_NAME); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_GET.apply(NOT_EXISTED_BRAND_ID); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{id}") + @Nested + class DeleteBrand { + @DisplayName("존재하는 brandId로 삭제하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenDeleteIsValid() { + // arrange + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_GET.apply(existingBrand.getId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.DELETE, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("존재하지 않는 brandId로 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenBrandIdDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_GET.apply(NOT_EXISTED_BRAND_ID); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.DELETE, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..42f2181f6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,125 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +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.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.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String VALID_BRAND_NAME = "아디다스"; + private static final Function ENDPOINT_GET = id -> "/api/v1/brands/" + id; + private static final Long NOT_EXISTED_BRAND_ID = 999L; + + private static final String SIGNUP_ENDPOINT = "/api/v1/users/signup"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String VALID_LOGIN_ID = "branduser1"; + private static final String VALID_PASSWORD = "brand@1234"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // AuthInterceptor 통과를 위한 유저 사전 등록 + void signUpUser() { + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, "브랜드유저", "1990-01-01", "brand@test.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + } + + HttpHeaders createUserHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + return headers; + } + + @DisplayName("GET /api/v1/brands/{id}") + @Nested + class GetBrandDetails { + @DisplayName("존재하는 brandId로 요청하면, 200 OK와 브랜드 정보를 반환한다.") + @Test + void returnsBrandDetails_whenGivenBrandIdIsValid() { + // arrange + signUpUser(); + Brand existingBrand = brandJpaRepository.save(new Brand(VALID_BRAND_NAME)); + String requestUrl = ENDPOINT_GET.apply(existingBrand.getId()); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isEqualTo(existingBrand.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_BRAND_NAME) + ); + } + + @DisplayName("존재하지 않는 brandId로 요청하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenBrandIdDoesNotExist() { + // arrange + signUpUser(); + String requestUrl = ENDPOINT_GET.apply(NOT_EXISTED_BRAND_ID); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} From bd5d301807c4db76faf924f97b88b6b7bc416ce8 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Wed, 25 Feb 2026 01:35:17 +0900 Subject: [PATCH 10/18] =?UTF-8?q?[docs]=20:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/01-requirements.md | 54 +------ .docs/design/02-sequence-diagrams.md | 217 ++------------------------- .docs/design/03-class-diagrams.md | 53 +------ .docs/design/04-erd.md | 41 +---- 4 files changed, 19 insertions(+), 346 deletions(-) diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index 431df28f5..738de7847 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -23,7 +23,6 @@ - **브랜드**: 조회(고객), 등록/수정/삭제(관리자) - **상품**: 조회(고객), 등록/수정/삭제(관리자) - **좋아요**: 상품에 대한 좋아요 등록/취소/목록 조회 -- **장바구니**: 상품 담기/수량 변경/제거/조회 - **주문**: 주문 생성, 주문 내역 조회 > 회원 도메인(가입, 인증, 내 정보 관리)은 이미 구현 완료되었으므로 본 문서의 범위에서 제외한다. @@ -62,13 +61,6 @@ |--------|------|------| | 좋아요 | Like | 회원이 특정 상품에 대해 관심을 표시하는 행위. 회원당 상품당 하나만 존재한다 | -#### 장바구니 - -| 한국어 | 영문 | 정의 | -|--------|------|------| -| 장바구니 | Cart | 회원이 구매를 고려하는 상품을 임시로 모아두는 공간. 회원당 하나 존재한다 | -| 장바구니 항목 | Cart Item | 장바구니에 담긴 개별 상품과 그 수량 | - #### 주문 | 한국어 | 영문 | 정의 | @@ -175,30 +167,7 @@ --- -### 2.4 장바구니 (Cart) - -#### US-C01: 장바구니에 상품 담기 - -> 회원은 구매를 고려하는 상품을 장바구니에 담을 수 있다. - -- 담을 상품과 수량을 지정한다. -- 이미 장바구니에 있는 상품을 다시 담으면, 수량이 누적된다. - -#### US-C02: 장바구니 조회 - -> 회원은 자신의 장바구니에 담긴 상품 목록을 조회할 수 있다. - -#### US-C03: 장바구니 상품 수량 변경 - -> 회원은 장바구니에 담긴 상품의 수량을 변경할 수 있다. - -#### US-C04: 장바구니 상품 제거 - -> 회원은 장바구니에서 특정 상품을 제거할 수 있다. - ---- - -### 2.5 주문 (Order) +### 2.4 주문 (Order) #### US-O01: 주문 생성 @@ -264,16 +233,7 @@ | BR-L02 | 좋아요를 등록하지 않은 상품에 대해 취소할 수 없다 | | | BR-L03 | 회원은 자신의 좋아요 목록만 조회할 수 있다 | | -### 3.5 장바구니 - -| 규칙 ID | 규칙 | 비고 | -|---------|------|------| -| BR-C01 | 회원은 하나의 장바구니를 가진다 | | -| BR-C02 | 이미 장바구니에 있는 상품을 다시 담으면, 기존 수량에 누적된다 | | -| BR-C03 | 장바구니 항목의 수량은 1 이상이어야 한다 | | -| BR-C04 | 회원은 자신의 장바구니만 조회/수정할 수 있다 | | - -### 3.6 주문 +### 3.5 주문 | 규칙 ID | 규칙 | 비고 | |---------|------|------| @@ -319,15 +279,7 @@ | 좋아요하지 않은 상품에 좋아요 취소 시도 | 취소를 거부하고, 좋아요 상태가 아님을 안내한다 | | 존재하지 않는 상품에 좋아요 시도 | 등록을 거부한다 | -### 4.5 장바구니 - -| 예외 상황 | 정책 | -|-----------|------| -| 존재하지 않는 상품을 장바구니에 담으려는 경우 | 담기를 거부한다 | -| 장바구니에 없는 상품의 수량을 변경하려는 경우 | 변경을 거부한다 | -| 수량을 0 이하로 변경하려는 경우 | 변경을 거부한다 | - -### 4.6 주문 +### 4.5 주문 | 예외 상황 | 정책 | |-----------|------| diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index c17917742..be9a86707 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -4,7 +4,7 @@ ### 인증/인가 -- 회원 전용 기능(좋아요, 장바구니, 주문)과 관리자 전용 기능(브랜드/상품 관리)은 AuthInterceptor에서 인증을 선처리한다. +- 회원 전용 기능(좋아요, 주문)과 관리자 전용 기능(브랜드/상품 관리)은 AuthInterceptor에서 인증을 선처리한다. - 인증 실패 시 Controller에 도달하기 전에 요청이 거부된다. - 아래 다이어그램은 **인증이 통과된 이후의 흐름**만 표현한다. @@ -239,7 +239,7 @@ sequenceDiagram #### 검증 목적 -BR-B01(연쇄 삭제)의 책임이 어느 계층에 있는지 확인한다. 브랜드 삭제 → 상품 삭제 → 좋아요 삭제의 3단계 연쇄가 발생하며, Facade가 BrandService, ProductService, LikeService를 조율한다. +BR-B01(연쇄 삭제)의 책임이 어느 계층에 있는지 확인한다. 브랜드 삭제 → 좋아요 삭제 → 상품 삭제의 3단계 연쇄가 발생하며, Facade가 BrandService, ProductService, LikeService를 조율한다. #### 시퀀스 다이어그램 @@ -253,8 +253,6 @@ sequenceDiagram participant BrandRepository participant LikeService participant LikeRepository - participant CartService - participant CartRepository participant ProductService participant ProductRepository @@ -277,10 +275,6 @@ sequenceDiagram LikeService->>LikeRepository: 좋아요 전체 삭제 (hard delete) LikeRepository-->>LikeService: 삭제 완료 LikeService-->>Facade: 삭제 완료 - Facade->>CartService: 해당 브랜드 상품 장바구니 항목 전체 삭제 - CartService->>CartRepository: 장바구니 항목 전체 삭제 (hard delete) - CartRepository-->>CartService: 삭제 완료 - CartService-->>Facade: 삭제 완료 Facade->>ProductService: 해당 브랜드 상품 전체 삭제 ProductService->>ProductRepository: 상품 전체 삭제 (soft delete) ProductRepository-->>ProductService: 삭제 완료 @@ -295,13 +289,13 @@ sequenceDiagram #### 봐야 할 포인트 -1. **Facade의 4-서비스 조율**: BrandService, LikeService, CartService, ProductService는 서로를 모른다. 도메인 간 삭제 순서를 Facade가 결정한다. -2. **삭제 순서와 정책**: 좋아요(hard delete) → 장바구니 항목(hard delete) → 상품(soft delete) → 브랜드(soft delete). 종속 데이터를 먼저 정리해야 상위 엔티티 삭제 후 고아 데이터가 남지 않는다. +1. **Facade의 3-서비스 조율**: BrandService, LikeService, ProductService는 서로를 모른다. 도메인 간 삭제 순서를 Facade가 결정한다. +2. **삭제 순서와 정책**: 좋아요(hard delete) → 상품(soft delete) → 브랜드(soft delete). 종속 데이터를 먼저 정리해야 상위 엔티티 삭제 후 고아 데이터가 남지 않는다. #### 잠재 리스크 -- **트랜잭션 범위**: 좋아요 삭제, 장바구니 항목 삭제, 상품 삭제, 브랜드 삭제가 하나의 트랜잭션으로 묶여야 한다. 4개 서비스를 포함하므로 트랜잭션이 넓다. -- **Soft Delete 연쇄 정책**: 브랜드 복원 시 상품도 함께 복원해야 하는지, 복원된 상품의 좋아요와 장바구니 항목은 이미 hard delete되어 복원 불가능한 점을 어떻게 다룰지 정책 결정이 필요하다. +- **트랜잭션 범위**: 좋아요 삭제, 상품 삭제, 브랜드 삭제가 하나의 트랜잭션으로 묶여야 한다. 3개 서비스를 포함하므로 트랜잭션이 넓다. +- **Soft Delete 연쇄 정책**: 브랜드 복원 시 상품도 함께 복원해야 하는지, 복원된 상품의 좋아요는 이미 hard delete되어 복원 불가능한 점을 어떻게 다룰지 정책 결정이 필요하다. --- @@ -591,8 +585,6 @@ sequenceDiagram participant ProductRepository participant LikeService participant LikeRepository - participant CartService - participant CartRepository 관리자->>Controller: 상품 삭제 요청 Controller->>Facade: 상품 삭제 위임 @@ -613,10 +605,6 @@ sequenceDiagram LikeService->>LikeRepository: 좋아요 전체 삭제 (hard delete) LikeRepository-->>LikeService: 삭제 완료 LikeService-->>Facade: 삭제 완료 - Facade->>CartService: 해당 상품 장바구니 항목 전체 삭제 - CartService->>CartRepository: 장바구니 항목 전체 삭제 (hard delete) - CartRepository-->>CartService: 삭제 완료 - CartService-->>Facade: 삭제 완료 Facade->>ProductService: 상품 삭제 ProductService->>ProductRepository: 상품 삭제 (soft delete) ProductRepository-->>ProductService: 삭제 완료 @@ -627,8 +615,8 @@ sequenceDiagram #### 봐야 할 포인트 -1. **삭제 정책의 혼합**: 좋아요(hard delete) → 장바구니 항목(hard delete) → 상품(soft delete) 순서로 처리한다. 종속 데이터를 먼저 정리해야 soft delete된 상품에 고아 데이터가 남는 불일치를 방지한다. -2. **US-B06과 동일한 패턴**: 브랜드 삭제 시 상품을 정리하듯, 상품 삭제 시 좋아요와 장바구니 항목을 정리한다. Facade가 도메인 간 삭제 순서를 결정한다. +1. **삭제 정책의 혼합**: 좋아요(hard delete) → 상품(soft delete) 순서로 처리한다. 종속 데이터를 먼저 정리해야 soft delete된 상품에 고아 데이터가 남는 불일치를 방지한다. +2. **US-B06과 동일한 패턴**: 브랜드 삭제 시 상품을 정리하듯, 상품 삭제 시 좋아요를 정리한다. Facade가 도메인 간 삭제 순서를 결정한다. #### 상품 도메인 잠재 리스크 @@ -794,194 +782,7 @@ sequenceDiagram --- -## 2.4 장바구니 (Cart) - -### US-C01: 장바구니에 상품 담기 - -#### 검증 목적 - -BR-C02에 따라 이미 장바구니에 있는 상품을 다시 담으면 수량이 누적된다. "신규 추가"와 "수량 누적"의 분기 처리 책임이 어느 계층에 있는지 확인한다. - -#### 시퀀스 다이어그램 - -```mermaid -sequenceDiagram - autonumber - actor 회원 - participant Controller as CartV1Controller - participant Facade as CartFacade - participant ProductService - participant ProductRepository - participant CartService - participant CartRepository - - 회원->>Controller: 장바구니 담기 요청 (상품, 수량) - Controller->>Facade: 장바구니 담기 위임 - Facade->>ProductService: 상품 존재 확인 - ProductService->>ProductRepository: 상품 존재 여부 확인 - - alt 상품이 존재하지 않는 경우 - ProductRepository-->>ProductService: 없음 - ProductService->>ProductService: 비즈니스 예외 발생 - ProductService-->>Facade: 예외 전파 - Facade-->>Controller: 예외 전파 - Controller-->>회원: 상품이 존재하지 않음 안내 - end - - ProductRepository-->>ProductService: 상품 정보 - ProductService-->>Facade: 상품 정보 - Facade->>CartService: 장바구니에 상품 담기 - CartService->>CartRepository: 장바구니 항목 조회 - - alt 이미 장바구니에 있는 상품인 경우 - CartRepository-->>CartService: 기존 장바구니 항목 - CartService->>CartRepository: 수량 누적 후 저장 - CartRepository-->>CartService: 저장 완료 - else 새로운 상품인 경우 - CartRepository-->>CartService: 없음 - CartService->>CartRepository: 새 장바구니 항목 저장 - CartRepository-->>CartService: 저장 완료 - end - - CartService-->>Facade: 담기 완료 - Facade-->>Controller: 담기 완료 - Controller-->>회원: 장바구니 담기 완료 응답 -``` - -#### 봐야 할 포인트 - -1. **수량 누적 판단의 책임**: CartService가 CartRepository를 통해 기존 항목 존재 여부를 확인하고, 존재하면 수량을 누적, 없으면 새 항목을 생성한다. Facade는 "담기"를 요청할 뿐, 신규/누적 분기를 알 필요가 없다. -2. **상품 검증은 Facade 책임**: 도메인 간 검증(상품 존재)은 US-P05, US-L01과 동일하게 Facade가 조율한다. - ---- - -### US-C02: 장바구니 조회 - -#### 검증 목적 - -회원이 자신의 장바구니만 조회하는 흐름(BR-C04)을 확인한다. - -#### 시퀀스 다이어그램 - -```mermaid -sequenceDiagram - autonumber - actor 회원 - participant Controller as CartV1Controller - participant Facade as CartFacade - participant Service as CartService - participant Repository as CartRepository - - 회원->>Controller: 장바구니 조회 요청 - Controller->>Facade: 장바구니 조회 위임 - Facade->>Service: 장바구니 조회 - Service->>Repository: 회원의 장바구니 항목 조회 - Repository-->>Service: 장바구니 항목 목록 - Service-->>Facade: 장바구니 항목 목록 - Facade-->>Controller: 장바구니 항목 목록 - Controller-->>회원: 장바구니 조회 응답 -``` - -#### 봐야 할 포인트 - -1. **BR-C04 소유권 제한**: 인증된 회원 ID 기준으로 자신의 장바구니만 조회한다. -2. **빈 장바구니도 정상 응답**: 장바구니에 항목이 없어도 빈 목록으로 정상 응답한다. - ---- - -### US-C03: 장바구니 상품 수량 변경 - -#### 검증 목적 - -수량 변경 시 BR-C03(수량 1 이상)과 장바구니 항목 존재 여부를 어느 계층에서 검증하는지 확인한다. - -#### 시퀀스 다이어그램 - -```mermaid -sequenceDiagram - autonumber - actor 회원 - participant Controller as CartV1Controller - participant Facade as CartFacade - participant Service as CartService - participant Repository as CartRepository - - 회원->>Controller: 수량 변경 요청 (상품, 새 수량) - Controller->>Facade: 수량 변경 위임 - Facade->>Service: 수량 변경 - Service->>Repository: 장바구니 항목 조회 - - alt 장바구니에 해당 상품이 없는 경우 - Repository-->>Service: 없음 - Service->>Service: 비즈니스 예외 발생 - Service-->>Facade: 예외 전파 - Facade-->>Controller: 예외 전파 - Controller-->>회원: 장바구니에 해당 상품 없음 안내 - end - - Repository-->>Service: 장바구니 항목 - Service->>Repository: 수량 변경 - Repository-->>Service: 변경 완료 - Service-->>Facade: 변경 완료 - Facade-->>Controller: 변경 완료 - Controller-->>회원: 수량 변경 완료 응답 -``` - -#### 봐야 할 포인트 - -1. **BR-C03 수량 검증 위치**: 수량이 1 이상인지 검증은 Service 또는 도메인 모델에서 처리한다. Controller의 요청 검증(@Valid)에서 먼저 걸러낼 수도 있다. - ---- - -### US-C04: 장바구니 상품 제거 - -#### 검증 목적 - -장바구니 항목 제거의 흐름을 확인한다. 존재하지 않는 항목 제거 시도에 대한 예외 처리를 검증한다. - -#### 시퀀스 다이어그램 - -```mermaid -sequenceDiagram - autonumber - actor 회원 - participant Controller as CartV1Controller - participant Facade as CartFacade - participant Service as CartService - participant Repository as CartRepository - - 회원->>Controller: 장바구니 상품 제거 요청 - Controller->>Facade: 상품 제거 위임 - Facade->>Service: 장바구니 항목 제거 - Service->>Repository: 장바구니 항목 조회 - - alt 장바구니에 해당 상품이 없는 경우 - Repository-->>Service: 없음 - Service->>Service: 비즈니스 예외 발생 - Service-->>Facade: 예외 전파 - Facade-->>Controller: 예외 전파 - Controller-->>회원: 장바구니에 해당 상품 없음 안내 - end - - Repository-->>Service: 장바구니 항목 - Service->>Repository: 항목 삭제 - Repository-->>Service: 삭제 완료 - Service-->>Facade: 제거 완료 - Facade-->>Controller: 제거 완료 - Controller-->>회원: 상품 제거 완료 응답 -``` - -#### 봐야 할 포인트 - -1. **US-C03과 동일한 전제**: 장바구니 항목의 존재 여부를 먼저 확인한다. early-return으로 예외를 빼고 정상 흐름은 블록 바깥에 둔다. - -#### 장바구니 도메인 잠재 리스크 - -- **수량 누적의 상한**: BR-C02에서 수량 누적에 상한이 없다. 재고보다 많은 수량을 장바구니에 담는 것을 허용할지, 담기 시점에 재고를 검증할지 결정이 필요하다. - ---- - -## 2.5 주문 (Order) +## 2.4 주문 (Order) ### US-O01: 주문 생성 diff --git a/.docs/design/03-class-diagrams.md b/.docs/design/03-class-diagrams.md index cb4f4f1af..b74ea1b32 100644 --- a/.docs/design/03-class-diagrams.md +++ b/.docs/design/03-class-diagrams.md @@ -21,7 +21,7 @@ |----|--------|----------| | Money | Product.price, OrderItem.price | 2곳 재사용 + 음수 불가 검증 | | Stock | Product.stock | 자체 행위 3개 (decrease, increase, hasEnough) → Product 책임 분산 | -| Quantity | CartItem.quantity, OrderItem.quantity | 2곳 재사용 + >= 1 검증 (BR-C03, BR-O02) | +| Quantity | OrderItem.quantity | 자체 검증 규칙 (>= 1, BR-O02) | ### 연관 관계 원칙 @@ -61,20 +61,18 @@ classDiagram Brand "1" <.. "*" Product : brandId Product "1" <.. "*" Like : productId - Product "1" <.. "*" CartItem : productId Product "1" <.. "*" OrderItem : productId Order "1" *-- "1..*" OrderItem : orderItems Product *-- Money : price Product *-- Stock : stock - CartItem *-- Quantity : quantity OrderItem *-- Quantity : quantity OrderItem *-- Money : price ``` ### 봐야 할 포인트 -1. **Like, CartItem은 BaseEntity 미상속**: 둘 다 hard delete 정책이므로 `deletedAt`이 불필요하다. BaseEntity를 상속하면 사용하지 않는 `deletedAt` 컬럼과 `delete()`/`restore()` 메서드가 노출되어 상속 계약을 위반한다. 나머지 4개 엔티티(Brand, Product, Order, OrderItem)는 soft delete를 사용한다. +1. **Like는 BaseEntity 미상속**: hard delete 정책이므로 `deletedAt`이 불필요하다. BaseEntity를 상속하면 사용하지 않는 `deletedAt` 컬럼과 `delete()`/`restore()` 메서드가 노출되어 상속 계약을 위반한다. 나머지 4개 엔티티(Brand, Product, Order, OrderItem)는 soft delete를 사용한다. 2. **ID 참조**: 점선 화살표(`<..`)는 Long 타입 ID로 참조하는 느슨한 연관이다. JPA `@ManyToOne`이 아닌 `Long brandId` 필드로 표현된다. 3. **유일한 composition**: Order → OrderItem만 실선 다이아몬드(`*--`)로 표현한다. OrderItem은 Order 없이 존재할 수 없다. @@ -224,49 +222,6 @@ classDiagram --- -## 장바구니 (CartItem + Quantity VO) - -### 검증 목적 - -CartItem이 Quantity VO를 통해 수량 검증(BR-C03)을 위임하는 구조와, 수량 누적(BR-C02)이 도메인 모델의 행위 메서드로 표현되는지 확인한다. Like와 마찬가지로 hard delete 정책이므로 BaseEntity를 상속하지 않는다. - -### 다이어그램 - -```mermaid -classDiagram - class CartItem { - -Long id - -Long userId - -Long productId - -Quantity quantity - -ZonedDateTime createdAt - +CartItem(Long userId, Long productId, Quantity quantity) - +addQuantity(Quantity quantity) void - +changeQuantity(Quantity quantity) void - } - - class Quantity { - <> - -int value - +Quantity(int value) - +add(Quantity other) Quantity - } - - CartItem *-- Quantity : quantity - - note for CartItem "BaseEntity 미상속\nhard delete 정책\ndeletedAt 불필요" -``` - -### 봐야 할 포인트 - -1. **BaseEntity 미상속 이유**: CartItem은 hard delete 정책이다. 회원이 장바구니에서 상품을 제거하면 물리 삭제하며, 관리자가 상품/브랜드를 삭제할 때도 해당 장바구니 항목을 물리 삭제한다. 이력 보존이 불필요하므로 `deletedAt`이 필요 없고, BaseEntity의 `delete()`/`restore()` 메서드가 노출되면 안 된다. -2. **addQuantity()**: BR-C02(수량 누적)를 구현한다. 이미 장바구니에 있는 상품을 다시 담으면, CartService가 기존 CartItem의 `addQuantity()`를 호출하여 수량을 누적한다. 내부적으로 Quantity VO의 `add()`에 위임한다. -3. **changeQuantity()**: US-C03(수량 변경)을 구현한다. 새 Quantity를 받아 교체한다. Quantity 생성자에서 `value >= 1` 검증이 수행되므로, 0 이하 수량은 VO 레벨에서 거부된다. -4. **Quantity.add()**: 불변 VO이므로 두 Quantity의 합산 결과를 새 인스턴스로 반환한다. `value >= 1` 검증은 생성자에서 수행되므로 `add()` 결과도 자동으로 유효하다. -5. **Cart 엔티티 없음**: BR-C01("회원은 하나의 장바구니를 가진다")이지만, 장바구니 자체를 엔티티로 두지 않고 `CartItem.userId`로 회원의 장바구니를 식별한다. CartItem의 집합이 곧 해당 회원의 장바구니이다. - ---- - ## 주문 (Order + OrderItem) ### 검증 목적 @@ -347,7 +302,6 @@ classDiagram | Product | price | Money VO | 음수 검증 위임 | | Product | stock | Stock VO | 행위 3개(decrease, increase, hasEnough) 위임 | | Product | likeCount | int (단순) | 증감만, VO 불필요 | -| CartItem | quantity | Quantity VO | >= 1 검증 + 수량 합산 위임 | | OrderItem | quantity | Quantity VO | >= 1 검증 위임 | | OrderItem | productName, brandName | String (단순) | 스냅샷 필드, OrderItem 자체가 스냅샷이므로 VO 불필요 | | OrderItem | price | Money VO | Product.price와 동일 VO 재사용 | @@ -357,7 +311,7 @@ classDiagram | 리스크 | 설명 | 대응 | |--------|------|------| | **Stock 불변성과 JPA 매핑** | Stock VO가 불변이므로 `decrease()`가 새 인스턴스를 반환한다. JPA `@Embedded`로 매핑할 때 setter가 필요한지 확인이 필요하다 | `@Embedded` + `@Column`으로 매핑하되, JPA 접근용 protected 기본 생성자만 허용한다. 상태 변경은 `Product.decreaseStock()`이 새 Stock을 할당하는 방식으로 처리한다 | -| **Quantity 재사용 범위** | CartItem과 OrderItem에서 동일한 Quantity VO를 사용한다. 두 도메인의 수량 규칙이 달라질 가능성이 있다 | 현재는 동일한 규칙(>= 1)이므로 공유한다. 규칙이 분기되는 시점에 각 도메인 전용 VO로 분리한다 | +| **Quantity 확장 가능성** | 수량 규칙이 추후 도메인별로 달라질 수 있다 | 현재는 OrderItem에서만 사용한다. 규칙이 복잡해지는 시점에 전용 VO로 분리한다 | | **Money 확장 가능성** | 현재 `int amount`로 원화만 지원한다. 통화 단위가 추가되면 VO 구조가 변경된다 | 현재 범위에서는 원화 단일 통화로 충분하다. 다중 통화 요구가 확정되면 `currency` 필드를 추가한다 | ### 도메인 간 정합성 리스크 @@ -365,6 +319,5 @@ classDiagram | 리스크 | 관련 도메인 | 설명 | |--------|------------|------| | **좋아요 수 불일치** | Product ↔ Like | `Product.likeCount`와 실제 Like 레코드 수가 어긋날 수 있다. 트랜잭션 내 원자적 처리 + 배치 보정 전략이 필요하다 | -| ~~장바구니 상품 삭제~~ | ~~CartItem ↔ Product~~ | **해결됨**: 상품/브랜드 삭제 시 해당 장바구니 항목을 함께 물리 삭제한다. Like와 동일한 패턴 | | **스냅샷 시점 정합성** | OrderItem ↔ Product | Facade에서 상품 정보를 조회한 시점과 Order를 저장하는 시점 사이에 상품 정보가 변경될 수 있다. 트랜잭션 격리 수준으로 방어한다 | | **재고 동시성** | Product.stock ↔ Order | 동시 주문 시 재고가 음수가 될 수 있다. 비관적 잠금(SELECT FOR UPDATE) 또는 Stock VO의 `decrease()`에서 음수 검증으로 방어한다 | diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index a950a29a3..c7321e5ef 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -51,14 +51,6 @@ erDiagram datetime created_at } - cart_item { - bigint id PK - bigint user_id FK - bigint product_id FK - int quantity - datetime created_at - } - orders { bigint id PK bigint user_id FK @@ -81,11 +73,9 @@ erDiagram } users ||--o{ likes : "" - users ||--o{ cart_item : "" users ||--o{ orders : "" brand ||--o{ product : "" product ||--o{ likes : "" - product ||--o{ cart_item : "" product ||--o{ order_item : "" orders ||--|{ order_item : "" ``` @@ -94,7 +84,7 @@ erDiagram 1. **users 테이블은 기존 구현**: 회원 도메인은 이미 구현되어 있으며 본 ERD에서는 FK 참조 대상으로만 포함한다. 2. **orders ↔ order_item**: 유일한 `||--|{` 관계(1:1이상). Order는 최소 1개의 OrderItem을 포함해야 한다 (BR-O01). 나머지는 모두 `||--o{`(1:0이상)이다. -3. **likes, cart_item에 deleted_at 없음**: hard delete 정책이므로 soft delete 컬럼이 불필요하다. +3. **likes에 deleted_at 없음**: hard delete 정책이므로 soft delete 컬럼이 불필요하다. 4. **order_item의 스냅샷 컬럼**: `product_name`, `brand_name`, `price`는 주문 시점의 상품 정보 사본이다. product, brand 테이블의 현재 값과 무관하게 주문 기록을 보존한다 (BR-O05). --- @@ -158,25 +148,6 @@ erDiagram --- -### cart_item - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | | -| user_id | BIGINT | NOT NULL, FK → users(id) | 장바구니 소유 회원 | -| product_id | BIGINT | NOT NULL, FK → product(id) | 담긴 상품 | -| quantity | INT | NOT NULL, >= 1 | 수량 (Quantity VO) | -| created_at | DATETIME | NOT NULL | | - -**유일성 제약**: -- `uk_cart_item_user_product` → `UNIQUE(user_id, product_id)`: 회원당 상품당 하나의 장바구니 항목만 존재. 같은 상품을 다시 담으면 기존 항목의 수량을 누적한다 (BR-C02) -- hard delete 테이블이므로 **DB UNIQUE 제약으로 완전히 보장 가능** - -**인덱스**: -- `uk_cart_item_user_product`이 `(user_id, product_id)` 순서이므로, `user_id` 기준 조회(US-C02: 내 장바구니)를 커버한다 - ---- - ### orders | 컬럼 | 타입 | 제약 | 설명 | @@ -223,8 +194,6 @@ erDiagram | product.brand_id → brand(id) | 상품은 반드시 존재하는 브랜드에 속한다 (BR-P01) | Facade가 브랜드 존재 확인 후 상품 등록 (US-P05) | | likes.user_id → users(id) | 좋아요는 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | | likes.product_id → product(id) | 좋아요는 실존 상품만 가능 | Facade가 상품 존재 확인 후 좋아요 등록 (US-L01). 상품 삭제 시 likes를 먼저 hard delete (US-P07) | -| cart_item.user_id → users(id) | 장바구니는 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | -| cart_item.product_id → product(id) | 장바구니는 실존 상품만 가능 | Facade가 상품 존재 확인 후 담기 (US-C01). 상품 삭제 시 cart_item을 먼저 hard delete (US-P07) | | orders.user_id → users(id) | 주문은 실존 회원만 가능 | AuthInterceptor가 인증된 회원만 허용 | | order_item.order_id → orders(id) | 주문 항목은 반드시 주문에 소속 | Order Aggregate가 OrderItem 생명주기를 관리 (cascade) | | order_item.product_id → product(id) | 원본 상품 추적용 참조 | Facade가 상품 존재 및 재고 확인 후 주문 생성 (US-O01). 스냅샷 컬럼이 실제 데이터를 보존 | @@ -234,11 +203,11 @@ erDiagram 상품/브랜드 삭제 시 Facade가 종속 데이터를 먼저 정리한다 (시퀀스 다이어그램 US-B06, US-P07 참고). ``` -브랜드 삭제: likes(hard delete) → cart_item(hard delete) → product(soft delete) → brand(soft delete) -상품 삭제: likes(hard delete) → cart_item(hard delete) → product(soft delete) +브랜드 삭제: likes(hard delete) → product(soft delete) → brand(soft delete) +상품 삭제: likes(hard delete) → product(soft delete) ``` -종속 데이터(likes, cart_item)를 먼저 물리 삭제하므로, 상위 엔티티 soft delete 후에도 고아 FK가 남지 않는다. +종속 데이터(likes)를 먼저 물리 삭제하므로, 상위 엔티티 soft delete 후에도 고아 FK가 남지 않는다. ### 유일성 제약 @@ -249,7 +218,6 @@ erDiagram | 테이블 | 제약 | 비즈니스 규칙 | |--------|------|-------------| | likes | `UNIQUE(user_id, product_id)` | BR-L01: 회원당 상품당 좋아요 1개 | -| cart_item | `UNIQUE(user_id, product_id)` | BR-C02: 회원당 상품당 장바구니 항목 1개 | 행이 물리적으로 삭제되므로, 삭제 후 같은 조합으로 재등록이 가능하다. DB UNIQUE 제약이 완전하게 동작한다. @@ -293,7 +261,6 @@ VO의 검증 규칙이 DB 컬럼 제약으로도 방어된다. |--------|----------|------| | `idx_product_brand_id` | US-P01: 브랜드별 상품 필터링 | | | `uk_likes_user_product` | US-L03: 내 좋아요 목록 | UNIQUE 제약이 인덱스 역할도 수행 | -| `uk_cart_item_user_product` | US-C02: 내 장바구니 조회 | UNIQUE 제약이 인덱스 역할도 수행 | | `idx_orders_user_id_created_at` | US-O02: 기간별 주문 목록 | 복합 인덱스로 user_id 필터 + created_at 범위 검색을 커버 | | `idx_order_item_order_id` | US-O03, O05: 주문 상세 | 주문 ID로 주문 항목 일괄 조회 | From 5ae3714a69d8e7e36ea17c64557ca43c0a31857b Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Wed, 25 Feb 2026 01:35:33 +0900 Subject: [PATCH 11/18] =?UTF-8?q?[docs]=20:=20claude.md=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7bff1b1e6..d7f86ad98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,7 @@ Entity → ExampleInfo (application DTO) → ExampleV1Dto (API DTO) → ApiRespo - JaCoCo 코드 커버리지 (XML 리포트) - 프로파일: `test`, 순차 실행 (`maxParallelForks = 1`) - 테스트 데이터 중 여러 테스트에서 반복 사용되는 값은 클래스 레벨 상수(`private static final`)로 선언한다 +- 테스트 메서드 내부는 `// arrange` / `// act` / `// assert` 주석으로 단계를 구분한다. 단, 해당 단계에 작성할 코드가 없으면 주석을 생략한다. ### 코드 스타일 - Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Slf4j` From fdb2216b68dbbdc821fccd619f5a3a0109e6a2f0 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Wed, 25 Feb 2026 01:37:32 +0900 Subject: [PATCH 12/18] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandAdminFacade.java | 11 +- .../product/ProductAdminFacade.java | 57 +++ .../application/product/ProductFacade.java | 35 ++ .../application/product/ProductInfo.java | 31 ++ .../product/ProductRegisterCommand.java | 12 + .../product/ProductUpdateCommand.java | 13 + .../java/com/loopers/config/WebMvcConfig.java | 5 +- .../loopers/domain/brand/BrandRepository.java | 2 + .../loopers/domain/brand/BrandService.java | 9 + .../com/loopers/domain/product/Money.java | 23 + .../com/loopers/domain/product/Product.java | 103 +++++ .../domain/product/ProductRepository.java | 16 + .../domain/product/ProductService.java | 66 +++ .../com/loopers/domain/product/Quantity.java | 27 ++ .../com/loopers/domain/product/Stock.java | 39 ++ .../com/loopers/domain/user/UserModel.java | 5 +- .../brand/BrandRepositoryImpl.java | 5 + .../product/ProductJpaRepository.java | 26 ++ .../product/ProductRepositoryImpl.java | 53 +++ .../api/brand/BrandAdminV1Controller.java | 2 +- .../api/product/ProductAdminV1ApiSpec.java | 33 ++ .../api/product/ProductAdminV1Controller.java | 69 +++ .../api/product/ProductAdminV1Dto.java | 79 ++++ .../api/product/ProductSortType.java | 30 ++ .../api/product/ProductV1ApiSpec.java | 29 ++ .../api/product/ProductV1Controller.java | 42 ++ .../interfaces/api/product/ProductV1Dto.java | 47 ++ .../ProductServiceIntegrationTest.java | 264 ++++++++++++ .../loopers/domain/product/ProductTest.java | 310 ++++++++++++++ .../{ => brand}/BrandAdminV1ApiE2ETest.java | 4 +- .../api/{ => brand}/BrandV1ApiE2ETest.java | 4 +- .../api/product/ProductAdminV1ApiE2ETest.java | 402 ++++++++++++++++++ .../api/product/ProductV1ApiE2ETest.java | 160 +++++++ .../api/{ => user}/UserV1ApiE2ETest.java | 5 +- 34 files changed, 2004 insertions(+), 14 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.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/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductRegisterCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/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/ProductSortType.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/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => brand}/BrandAdminV1ApiE2ETest.java (99%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => brand}/BrandV1ApiE2ETest.java (98%) 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 rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => user}/UserV1ApiE2ETest.java (99%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 194b36d2c..99b75c9d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -11,10 +12,11 @@ @Component public class BrandAdminFacade { private final BrandService brandService; + private final ProductService productService; // 브랜드 등록 - public BrandInfo register(String name){ - Brand brand = brandService.register(name); + public BrandInfo register(BrandRegisterCommand command){ + Brand brand = brandService.register(command.name()); return BrandInfo.from(brand); } @@ -35,8 +37,11 @@ public BrandInfo update(BrandUpdateCommand command){ return BrandInfo.from(brand); } - // 브랜드 삭제 + // 브랜드 삭제 - 상품 cascade soft delete 후 브랜드 삭제 (US-B06) + // Like/Cart cascade는 해당 도메인 구현 시 추가 예정 public void delete(Long id){ + brandService.findById(id); // 브랜드 존재 확인 + productService.deleteAllByBrandId(id); brandService.delete(id); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java new file mode 100644 index 000000000..aedb871db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -0,0 +1,57 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +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.Map; + +@RequiredArgsConstructor +@Component +public class ProductAdminFacade { + + private final ProductService productService; + private final BrandService brandService; + + // 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05) + public ProductInfo register(ProductRegisterCommand command) { + Brand brand = brandService.findById(command.brandId()); // 브랜드 미존재 시 NOT_FOUND 예외 + Product product = productService.register( + command.brandId(), command.name(), command.price(), command.stock()); + return ProductInfo.from(product, brand.getName()); + } + + // 상품 상세 조회 + public ProductInfo findById(Long id) { + Product product = productService.findById(id); + String brandName = brandService.findById(product.getBrandId()).getName(); + return ProductInfo.from(product, brandName); + } + + // 상품 목록 조회 (brandId 필터 선택) + public Page findAll(Long brandId, Pageable pageable) { + Page products = productService.findAll(brandId, pageable); + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandNameMap = brandService.findNamesByIds(brandIds); + return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))); + } + + // 상품 정보 수정 + public ProductInfo update(ProductUpdateCommand command) { + Product product = productService.update( + command.id(), command.name(), command.price(), command.stock()); + String brandName = brandService.findById(product.getBrandId()).getName(); + return ProductInfo.from(product, brandName); + } + + // 상품 삭제 + public void delete(Long id) { + productService.delete(id); + } +} 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..0aca6dd28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +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.Map; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + // 상품 상세 조회 + public ProductInfo findById(Long id) { + Product product = productService.findById(id); + String brandName = brandService.findById(product.getBrandId()).getName(); + return ProductInfo.from(product, brandName); + } + + // 상품 목록 조회 (brandId 필터 선택) + public Page findAll(Long brandId, Pageable pageable) { + Page products = productService.findAll(brandId, pageable); + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandNameMap = brandService.findNamesByIds(brandIds); + return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))); + } +} 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..9131551c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static ProductInfo from(Product product, String brandName) { + return new ProductInfo( + product.getId(), + product.getBrandId(), + brandName, + product.getName(), + product.getPrice().getAmount(), + product.getStock().getQuantity(), + product.getLikeCount(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRegisterCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRegisterCommand.java new file mode 100644 index 000000000..f0c8a9a61 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRegisterCommand.java @@ -0,0 +1,12 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Stock; + +public record ProductRegisterCommand( + Long brandId, + String name, + Money price, + Stock stock +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java new file mode 100644 index 000000000..a8f2fd471 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java @@ -0,0 +1,13 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Stock; + +// 소속 브랜드는 수정 불가 (BR-P02) +public record ProductUpdateCommand( + Long id, + String name, + Money price, + Stock stock +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 289e7e179..8dc8453dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -23,7 +23,10 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") - .excludePathPatterns("/api/v1/users/signup"); + .excludePathPatterns("/api/v1/users/signup") + // BR-A03: 상품 조회 및 브랜드 조회는 인증 없이 접근 가능 (비회원/회원 모두 허용) + .excludePathPatterns("/api/v1/products/**") + .excludePathPatterns("/api/v1/brands/**"); registry.addInterceptor(adminAuthInterceptor) .addPathPatterns("/api-admin/**"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 57db0ab28..49fd49b57 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -3,11 +3,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface BrandRepository { Brand save(Brand brand); Optional findById(Long id); + List findAllByIds(List ids); Page findAll(Pageable pageable); boolean existsByName(String name); boolean existsByNameAndIdNot(String name, Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 570f175fa..4e21ddbce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -9,6 +9,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -32,6 +34,13 @@ public Brand findById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); } + // 브랜드 일괄 조회 (brandId → brandName 매핑용) + @Transactional(readOnly = true) + public Map findNamesByIds(List ids) { + return brandRepository.findAllByIds(ids).stream() + .collect(Collectors.toMap(Brand::getId, Brand::getName)); + } + // 브랜드 목록 조회 @Transactional(readOnly = true) public Page findAll(Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..b73ee0f8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Money { + + private int amount; + + public Money(int amount) { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + this.amount = amount; + } +} 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..057a62314 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,103 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Product extends BaseEntity { + + // 소속 브랜드 ID. 등록 이후 변경 불가 (BR-P02) + @Column(name = "brand_id", nullable = false, updatable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + @Embedded + @AttributeOverride(name = "amount", column = @Column(name = "price", nullable = false)) + private Money price; + + @Embedded + @AttributeOverride(name = "quantity", column = @Column(name = "stock", nullable = false)) + private Stock stock; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + public Product(Long brandId, String name, Money price, Stock stock) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStock(stock); + + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + this.likeCount = 0; + } + + public void update(String name, Money price, Stock stock) { + validateName(name); + validatePrice(price); + validateStock(stock); + + this.name = name; + this.price = price; + this.stock = stock; + } + + public void decreaseStock(Quantity quantity) { + this.stock = stock.decrease(quantity); + } + + public void increaseStock(Quantity quantity) { + this.stock = stock.increase(quantity); + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (likeCount > 0) { + this.likeCount--; + } + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + } + + private void validatePrice(Money price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 필수입니다."); + } + } + + private void validateStock(Stock stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 필수입니다."); + } + } +} 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..662adfdcc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + Page findAll(Pageable pageable); + Page findAllByBrandId(Long brandId, Pageable pageable); + boolean existsByBrandIdAndName(Long brandId, String name); + boolean existsByBrandIdAndNameAndIdNot(Long brandId, String name, Long id); + void deleteAllByBrandId(Long brandId); +} 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..2fd06e5d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,66 @@ +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.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + // 상품 등록 + @Transactional + public Product register(Long brandId, String name, Money price, Stock stock) { + if (productRepository.existsByBrandIdAndName(brandId, name)) { + throw new CoreException(ErrorType.CONFLICT, "해당 브랜드에 이미 존재하는 상품명입니다."); + } + Product product = new Product(brandId, name, price, stock); + return productRepository.save(product); + } + + // 상품 상세 조회 + @Transactional(readOnly = true) + public Product findById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + } + + // 상품 목록 조회 (brandId 필터 선택) + @Transactional(readOnly = true) + public Page findAll(Long brandId, Pageable pageable) { + if (brandId == null) { + return productRepository.findAll(pageable); + } + return productRepository.findAllByBrandId(brandId, pageable); + } + + // 상품 정보 수정 + @Transactional + public Product update(Long id, String name, Money price, Stock stock) { + Product product = findById(id); + if (productRepository.existsByBrandIdAndNameAndIdNot(product.getBrandId(), name, id)) { + throw new CoreException(ErrorType.CONFLICT, "해당 브랜드에 이미 존재하는 상품명입니다."); + } + product.update(name, price, stock); + return product; + } + + // 상품 삭제 + @Transactional + public void delete(Long id) { + Product product = findById(id); + product.delete(); + } + + // 브랜드 삭제 시 해당 브랜드 상품 전체 삭제 (cascade용) + @Transactional + public void deleteAllByBrandId(Long brandId) { + productRepository.deleteAllByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Quantity.java new file mode 100644 index 000000000..bd3b213aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Quantity.java @@ -0,0 +1,27 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Quantity { + + private int value; + + public Quantity(int value) { + if (value < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + this.value = value; + } + + public Quantity add(Quantity other) { + return new Quantity(this.value + other.value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..0b06ab617 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,39 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Stock { + + private int quantity; + + public Stock(int quantity) { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + this.quantity = quantity; + } + + public Stock decrease(Quantity quantity) { + int newQuantity = this.quantity - quantity.getValue(); + if (newQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Stock(newQuantity); + } + + public Stock increase(Quantity quantity) { + return new Stock(this.quantity + quantity.getValue()); + } + + public boolean hasEnough(Quantity quantity) { + return this.quantity >= quantity.getValue(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 7c7b0e3d6..23edfdf4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -6,6 +6,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,9 +14,9 @@ import java.time.format.DateTimeParseException; @Entity -@Table(name = "users") +@Table(name = "user") @Getter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserModel extends BaseEntity { @Column(nullable = false, unique = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index abbc1d4ac..5234ce944 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -20,6 +20,11 @@ public Optional findById(Long id){ return brandJpaRepository.findById(id); } + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllById(ids); + } + @Override public Page findAll(Pageable pageable) { return brandJpaRepository.findAll(pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..da3a33d89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; + +public interface ProductJpaRepository extends JpaRepository { + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + boolean existsByBrandIdAndNameAndDeletedAtIsNull(Long brandId, String name); + + boolean existsByBrandIdAndNameAndIdNotAndDeletedAtIsNull(Long brandId, String name, Long id); + + @Modifying + @Query("UPDATE Product p SET p.deletedAt = :deletedAt WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + void softDeleteAllByBrandId(@Param("brandId") Long brandId, @Param("deletedAt") ZonedDateTime deletedAt); +} 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..17548ca3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public boolean existsByBrandIdAndName(Long brandId, String name) { + return productJpaRepository.existsByBrandIdAndNameAndDeletedAtIsNull(brandId, name); + } + + @Override + public boolean existsByBrandIdAndNameAndIdNot(Long brandId, String name, Long id) { + return productJpaRepository.existsByBrandIdAndNameAndIdNotAndDeletedAtIsNull(brandId, name, id); + } + + @Override + public void deleteAllByBrandId(Long brandId) { + productJpaRepository.softDeleteAllByBrandId(brandId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 88eb7366d..fa95245d0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -27,7 +27,7 @@ public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { @Override public ApiResponse registerBrand( @RequestBody BrandAdminV1Dto.RegisterRequest request) { - BrandInfo info = brandAdminFacade.register(request.name()); + BrandInfo info = brandAdminFacade.register(request.toCommand()); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } 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..2ad253e85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,33 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "ProductAdmin V1 API", description = "관리자용 상품 관련 API 입니다.") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다. 상품은 이미 등록된 브랜드에 속해야 합니다.") + ApiResponse registerProduct(ProductAdminV1Dto.RegisterRequest request); + + @Operation(summary = "상품 상세 조회", description = "상품의 상세 정보를 조회합니다.") + ApiResponse getProductDetails(@Parameter(description = "상품 ID") long id); + + @Operation(summary = "상품 목록 조회", description = "등록된 상품 목록을 조회합니다. 브랜드 필터링과 정렬을 지원합니다.") + ApiResponse getProducts( + @Parameter(description = "브랜드 ID (선택)") Long brandId, + @Parameter(description = "정렬 기준 (기본값: latest). 허용값: latest(최신순), price_asc(가격 오름차순), likes_desc(좋아요 많은 순). 허용값 외 입력 시 400 에러") String sort, + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기") int size + ); + + @Operation(summary = "상품 정보 수정", description = "상품 정보를 수정합니다. 소속 브랜드는 변경할 수 없습니다.") + ApiResponse updateProduct( + @Parameter(description = "상품 ID") long id, + ProductAdminV1Dto.UpdateRequest request + ); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse deleteProduct(@Parameter(description = "상품 ID") long id); +} 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..8a35cb02c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductAdminFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductAdminFacade productAdminFacade; + + @PostMapping + @Override + public ApiResponse registerProduct( + @RequestBody ProductAdminV1Dto.RegisterRequest request) { + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from( + productAdminFacade.register(request.toCommand()))); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getProductDetails( + @PathVariable long id) { + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from( + productAdminFacade.findById(id))); + } + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + var pageable = PageRequest.of(page, size, ProductSortType.from(sort).toSort()); + Page result = productAdminFacade.findAll(brandId, pageable); + return ApiResponse.success(ProductAdminV1Dto.ProductListResponse.from(result)); + } + + @PutMapping("/{id}") + @Override + public ApiResponse updateProduct( + @PathVariable long id, + @RequestBody ProductAdminV1Dto.UpdateRequest request) { + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from( + productAdminFacade.update(request.toCommand(id)))); + } + + @DeleteMapping("/{id}") + @Override + public ApiResponse deleteProduct(@PathVariable long id) { + productAdminFacade.delete(id); + return ApiResponse.success(null); + } +} 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..1e4bd93d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductRegisterCommand; +import com.loopers.application.product.ProductUpdateCommand; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Stock; +import org.springframework.data.domain.Page; + +import java.time.ZonedDateTime; +import java.util.List; + +public class ProductAdminV1Dto { + + public record ProductResponse( + long id, + long brandId, + String brandName, + String name, + int price, + int stock, // BR-P05: 관리자에게는 실제 재고 수량 노출 + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likeCount(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record ProductListResponse( + List products, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page info) { + return new ProductListResponse( + info.getContent().stream().map(ProductResponse::from).toList(), + info.getNumber(), + info.getSize(), + info.getTotalElements(), + info.getTotalPages() + ); + } + } + + public record RegisterRequest( + Long brandId, + String name, + int price, + int stock + ) { + public ProductRegisterCommand toCommand() { + return new ProductRegisterCommand(brandId, name, new Money(price), new Stock(stock)); + } + } + + public record UpdateRequest( + String name, + int price, + int stock + ) { + public ProductUpdateCommand toCommand(long id) { + return new ProductUpdateCommand(id, name, new Money(price), new Stock(stock)); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java new file mode 100644 index 000000000..e95822c01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.Sort; + +// 고객용 상품 목록 조회 시 지원하는 정렬 기준 +public enum ProductSortType { + + LATEST, // 최신순 (기본값) + PRICE_ASC, // 가격 오름차순 + LIKES_DESC; // 좋아요 많은 순 + + public static ProductSortType from(String value) { + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "지원하지 않는 정렬 기준입니다. 허용값: latest, price_asc, likes_desc"); + } + } + + public Sort toSort() { + return switch (this) { + case PRICE_ASC -> Sort.by("price.amount").ascending(); + case LIKES_DESC -> Sort.by("likeCount").descending(); + case LATEST -> Sort.by("createdAt").descending(); + }; + } +} 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..0a3d5dd42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,29 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "고객용 상품 관련 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 조회합니다. 브랜드 필터링과 정렬을 지원합니다." + ) + ApiResponse getProducts( + @Parameter(description = "브랜드 ID (선택)") Long brandId, + @Parameter(description = "정렬 기준 (기본값: latest). 허용값: latest(최신순), price_asc(가격 오름차순), likes_desc(좋아요 많은 순). 허용값 외 입력 시 400 에러") String sort, + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기") int size + ); + + @Operation( + summary = "상품 상세 조회", + description = "상품의 상세 정보를 조회합니다." + ) + ApiResponse getProductDetails( + @Parameter(description = "상품 ID") long id + ); +} 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..db64ef590 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) + { + var pageable = PageRequest.of(page, size, ProductSortType.from(sort).toSort()); + Page result = productFacade.findAll(brandId, pageable); + return ApiResponse.success(ProductV1Dto.ProductListResponse.from(result)); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getProductDetails( + @PathVariable long id) + { + return ApiResponse.success(ProductV1Dto.ProductResponse.from(productFacade.findById(id))); + } +} 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..103f4611d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class ProductV1Dto { + + public record ProductResponse( + long id, + long brandId, + String brandName, + String name, + int price, + boolean inStock // BR-P05: 고객에게는 재고 여부(있음/없음)만 노출 + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock() > 0 + ); + } + } + + public record ProductListResponse( + List products, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page info) { + return new ProductListResponse( + info.getContent().stream().map(ProductResponse::from).toList(), + info.getNumber(), + info.getSize(), + info.getTotalElements(), + info.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..2969c8e76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,264 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +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.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +public class ProductServiceIntegrationTest { + + private static final String VALID_PRODUCT_NAME = "나이키 에어맥스"; + private static final String NEW_PRODUCT_NAME = "나이키 조던"; + private static final Money VALID_PRICE = new Money(10000); + private static final Stock VALID_STOCK = new Stock(100); + private static final Long NOT_EXISTED_PRODUCT_ID = 999L; + + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품 등록 시") + @Nested + class Register { + + @DisplayName("중복되지 않는 상품명으로 등록에 성공한다.") + @Test + void registersProductSucceed_whenProductNameIsUnique() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + // act + Product result = productService.register(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + + // assert + assertThat(result.getId()).isPositive(); + assertThat(result.getName()).isEqualTo(VALID_PRODUCT_NAME); + + // DB에 실제로 저장됐는지 확인 + Product saved = productJpaRepository.findById(result.getId()).orElseThrow(); + assertThat(saved.getName()).isEqualTo(VALID_PRODUCT_NAME); + assertThat(saved.getPrice().getAmount()).isEqualTo(VALID_PRICE.getAmount()); + } + + @DisplayName("같은 브랜드에 중복되는 상품명으로 등록하면, CONFLICT 에러가 발생한다.") + @Test + void registersProductFail_whenProductNameIsDuplicated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + productJpaRepository.save(new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.register(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("상품 상세 조회 시") + @Nested + class FindById { + + @DisplayName("존재하는 productId로 조회하면, 상품 정보를 반환한다.") + @Test + void findByIdSucceed_whenProductIdExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product saved = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + Product result = productService.findById(saved.getId()); + + // assert + assertThat(result.getId()).isEqualTo(saved.getId()); + assertThat(result.getName()).isEqualTo(VALID_PRODUCT_NAME); + } + + @DisplayName("존재하지 않는 productId로 조회하면, NOT_FOUND 에러가 발생한다.") + @Test + void findByIdFail_whenProductIdDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.findById(NOT_EXISTED_PRODUCT_ID)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 정보 수정 시") + @Nested + class Update { + + @DisplayName("중복되지 않는 상품명으로 수정하면, 성공한다.") + @Test + void updateSucceed_whenProductNameIsUnique() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + Money newPrice = new Money(20000); + Stock newStock = new Stock(50); + + // act + productService.update(product.getId(), NEW_PRODUCT_NAME, newPrice, newStock); + + // assert - dirty checking으로 DB에 반영됐는지 확인 + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getName()).isEqualTo(NEW_PRODUCT_NAME); + assertThat(updated.getPrice().getAmount()).isEqualTo(20000); + assertThat(updated.getStock().getQuantity()).isEqualTo(50); + } + + @DisplayName("같은 브랜드에 중복되는 상품명으로 수정하면, CONFLICT 에러가 발생한다.") + @Test + void updateFail_whenProductNameIsDuplicated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + productJpaRepository.save( + new Product(brand.getId(), NEW_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.update(product1.getId(), NEW_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("존재하지 않는 productId로 수정하면, NOT_FOUND 에러가 발생한다.") + @Test + void updateFail_whenProductIdDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.update(NOT_EXISTED_PRODUCT_ID, VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 삭제 시") + @Nested + class Delete { + + @DisplayName("존재하는 productId로 삭제하면, soft delete가 적용된다.") + @Test + void deleteSucceed_whenProductIdExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + productService.delete(product.getId()); + + // assert - DB에서 재조회하여 deletedAt이 설정됐는지 확인 + Product deleted = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(deleted.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 productId로 삭제하면, NOT_FOUND 에러가 발생한다.") + @Test + void deleteFail_whenProductIdDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.delete(NOT_EXISTED_PRODUCT_ID)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드 삭제 시, 해당 브랜드의 모든 상품이 soft delete된다.") + @Test + void deleteAllByBrandIdSucceed_whenBrandIdExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + Product product2 = productJpaRepository.save( + new Product(brand.getId(), NEW_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + productService.deleteAllByBrandId(brand.getId()); + + // assert + Product deleted1 = productJpaRepository.findById(product1.getId()).orElseThrow(); + Product deleted2 = productJpaRepository.findById(product2.getId()).orElseThrow(); + assertThat(deleted1.getDeletedAt()).isNotNull(); + assertThat(deleted2.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("상품 목록 조회 시") + @Nested + class FindAll { + + @DisplayName("brandId 없이 조회하면, 전체 상품 목록을 반환한다.") + @Test + void returnsAllProducts_whenBrandIdIsNull() { + // arrange + Brand brand1 = brandJpaRepository.save(new Brand("나이키")); + Brand brand2 = brandJpaRepository.save(new Brand("아디다스")); + productJpaRepository.save(new Product(brand1.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + productJpaRepository.save(new Product(brand2.getId(), "아디다스 운동화", VALID_PRICE, VALID_STOCK)); + + // act + Page result = productService.findAll(null, + PageRequest.of(0, 20, Sort.by("createdAt").descending())); + + // assert + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId로 필터링하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdIsProvided() { + // arrange + Brand brand1 = brandJpaRepository.save(new Brand("나이키")); + Brand brand2 = brandJpaRepository.save(new Brand("아디다스")); + productJpaRepository.save(new Product(brand1.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + productJpaRepository.save(new Product(brand2.getId(), "아디다스 운동화", VALID_PRICE, VALID_STOCK)); + + // act + Page result = productService.findAll(brand1.getId(), + PageRequest.of(0, 20, Sort.by("createdAt").descending())); + + // assert + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getBrandId()).isEqualTo(brand1.getId()); + } + } +} 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..8c5ab0f23 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,310 @@ +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.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + private static final Long VALID_BRAND_ID = 1L; + private static final String VALID_NAME = "나이키 에어맥스"; + private static final Money VALID_PRICE = new Money(10000); + private static final Stock VALID_STOCK = new Stock(100); + + @DisplayName("Money VO를 생성할 때") + @Nested + class MoneyCreate { + + @DisplayName("금액이 0 이상이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenAmountIsZeroOrPositive() { + // act + Money zero = new Money(0); + Money positive = new Money(50000); + + // assert + assertThat(zero.getAmount()).isEqualTo(0); + assertThat(positive.getAmount()).isEqualTo(50000); + } + + @DisplayName("금액이 음수이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenAmountIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Quantity VO를 생성할 때") + @Nested + class QuantityCreate { + + @DisplayName("수량이 1 이상이면, 정상적으로 생성된다.") + @Test + void createsQuantity_whenValueIsPositive() { + // act + Quantity quantity = new Quantity(1); + + // assert + assertThat(quantity.getValue()).isEqualTo(1); + } + + @DisplayName("수량이 0 이하이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenValueIsZeroOrNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Quantity(0)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Stock VO를 생성할 때") + @Nested + class StockCreate { + + @DisplayName("재고가 0 이상이면, 정상적으로 생성된다.") + @Test + void createsStock_whenQuantityIsZeroOrPositive() { + // act + Stock zero = new Stock(0); + Stock positive = new Stock(50); + + // assert + assertThat(zero.getQuantity()).isEqualTo(0); + assertThat(positive.getQuantity()).isEqualTo(50); + } + + @DisplayName("재고가 음수이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Stock(-1)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 감소시킬 때, 충분한 재고가 있으면 감소된 재고를 반환한다.") + @Test + void decreasesStock_whenStockIsEnough() { + // arrange + Stock stock = new Stock(10); + Quantity quantity = new Quantity(3); + + // act + Stock decreased = stock.decrease(quantity); + + // assert + assertThat(decreased.getQuantity()).isEqualTo(7); + } + + @DisplayName("재고를 감소시킬 때, 재고가 부족하면 예외가 발생한다.") + @Test + void throwsBadRequestException_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(2); + Quantity quantity = new Quantity(5); + + // act + CoreException result = assertThrows(CoreException.class, () -> stock.decrease(quantity)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 증가시키면, 증가된 재고를 반환한다.") + @Test + void increasesStock_whenCalled() { + // arrange + Stock stock = new Stock(5); + Quantity quantity = new Quantity(3); + + // act + Stock increased = stock.increase(quantity); + + // assert + assertThat(increased.getQuantity()).isEqualTo(8); + } + + @DisplayName("재고가 충분한지 확인할 때, 재고가 충분하면 true를 반환한다.") + @Test + void returnsTrue_whenStockIsEnough() { + // arrange + Stock stock = new Stock(10); + + // assert - hasEnough 호출과 검증을 한 번에 표현 + assertThat(stock.hasEnough(new Quantity(10))).isTrue(); + assertThat(stock.hasEnough(new Quantity(11))).isFalse(); + } + } + + @DisplayName("상품을 생성할 때") + @Nested + class Create { + + @DisplayName("모든 정보가 올바르면, 상품이 정상적으로 생성된다.") + @Test + void createsProduct_whenAllInfoIsValid() { + // act + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // assert + assertThat(product.getBrandId()).isEqualTo(VALID_BRAND_ID); + assertThat(product.getName()).isEqualTo(VALID_NAME); + assertThat(product.getPrice().getAmount()).isEqualTo(VALID_PRICE.getAmount()); + assertThat(product.getStock().getQuantity()).isEqualTo(VALID_STOCK.getQuantity()); + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("브랜드 ID가 null이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenBrandIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Product(null, VALID_NAME, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 null이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Product(VALID_BRAND_ID, null, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 비어있으면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Product(VALID_BRAND_ID, " ", VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 정보를 수정할 때") + @Nested + class Update { + + @DisplayName("올바른 정보로 수정하면, 상품 정보가 변경된다.") + @Test + void updatesProduct_whenNewInfoIsValid() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + Money newPrice = new Money(20000); + Stock newStock = new Stock(50); + + // act + product.update("나이키 조던", newPrice, newStock); + + // assert + assertThat(product.getName()).isEqualTo("나이키 조던"); + assertThat(product.getPrice().getAmount()).isEqualTo(20000); + assertThat(product.getStock().getQuantity()).isEqualTo(50); + } + + @DisplayName("상품명이 null이면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewNameIsNull() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // act + CoreException result = assertThrows(CoreException.class, + () -> product.update(null, VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 비어있으면, 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewNameIsBlank() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // act + CoreException result = assertThrows(CoreException.class, + () -> product.update(" ", VALID_PRICE, VALID_STOCK)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수정 후에도 브랜드 ID는 변경되지 않는다.") + @Test + void doesNotChangeBrandId_afterUpdate() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // act + product.update("다른상품", new Money(5000), new Stock(10)); + + // assert + assertThat(product.getBrandId()).isEqualTo(VALID_BRAND_ID); + } + } + + @DisplayName("좋아요 수를 변경할 때") + @Nested + class LikeCount { + + @DisplayName("좋아요를 증가시키면, likeCount가 1 증가한다.") + @Test + void increasesLikeCount_whenCalled() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // act + product.increaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 감소시킬 때, likeCount가 0보다 크면 1 감소한다.") + @Test + void decreasesLikeCount_whenLikeCountIsPositive() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + product.increaseLikeCount(); + + // act + product.decreaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("좋아요를 감소시킬 때, likeCount가 0이면 변경되지 않는다.") + @Test + void doesNotDecreaseBelowZero_whenLikeCountIsZero() { + // arrange + Product product = new Product(VALID_BRAND_ID, VALID_NAME, VALID_PRICE, VALID_STOCK); + + // act + product.decreaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java index 270428cc1..7046f6c26 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.brand; import com.loopers.domain.brand.Brand; import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index 42f2181f6..22e17cb5b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.brand; import com.loopers.domain.brand.Brand; import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.user.UserV1Dto; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; 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..c2316b8c4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ApiE2ETest.java @@ -0,0 +1,402 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +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.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.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductAdminV1ApiE2ETest { + + private static final String VALID_PRODUCT_NAME = "나이키 에어맥스"; + private static final String NEW_PRODUCT_NAME = "나이키 조던"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + private static final Long NOT_EXISTED_BRAND_ID = 999L; + private static final Long NOT_EXISTED_PRODUCT_ID = 999L; + + private static final String ENDPOINT_POST = "/api-admin/v1/products"; + private static final String ENDPOINT_GET_LIST = "/api-admin/v1/products"; + private static final Function ENDPOINT_PRODUCT = id -> "/api-admin/v1/products/" + id; + + private static final String HEADER_ADMIN_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + + @Autowired + public ProductAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); + return headers; + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Register { + + @DisplayName("정상적인 상품 정보로 등록하면, 200 OK와 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenRegisterIsValid() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.RegisterRequest request = + new ProductAdminV1Dto.RegisterRequest(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isPositive(), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_PRODUCT_NAME), + () -> assertThat(response.getBody().data().price()).isEqualTo(VALID_PRICE), + () -> assertThat(response.getBody().data().stock()).isEqualTo(VALID_STOCK) + ); + } + + @DisplayName("관리자 인증 헤더가 누락되면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenHeaderIsMissing() { + // arrange + ProductAdminV1Dto.RegisterRequest request = + new ProductAdminV1Dto.RegisterRequest(1L, VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.UNAUTHORIZED.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 브랜드로 등록하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.RegisterRequest request = + new ProductAdminV1Dto.RegisterRequest(NOT_EXISTED_BRAND_ID, VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("같은 브랜드에 중복된 상품명으로 등록하면, 409 CONFLICT 응답을 받는다.") + @Test + void returnsConflict_whenProductNameIsDuplicated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + productJpaRepository.save(new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.RegisterRequest request = + new ProductAdminV1Dto.RegisterRequest(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_POST, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.CONFLICT.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{id}") + @Nested + class GetProductDetails { + + @DisplayName("존재하는 productId로 요청하면, 200 OK와 상품 상세 정보를 반환한다.") + @Test + void returnsProductDetails_whenProductIdIsValid() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_PRODUCT.apply(product.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_PRODUCT_NAME), + () -> assertThat(response.getBody().data().stock()).isEqualTo(VALID_STOCK) + ); + } + + @DisplayName("존재하지 않는 productId로 요청하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenProductIdDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_PRODUCT.apply(NOT_EXISTED_PRODUCT_ID); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class GetProductList { + + @DisplayName("등록된 상품이 있을 때 목록을 조회하면, 200 OK와 상품 목록을 반환한다.") + @Test + void returnsProductList_whenProductsExist() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET_LIST, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().products().get(0).name()).isEqualTo(VALID_PRODUCT_NAME) + ); + } + + @DisplayName("허용되지 않는 sort 값으로 요청하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void returnsBadRequest_whenSortIsInvalid() { + // arrange + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_GET_LIST + "?sort=invalid"; + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.BAD_REQUEST.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{id}") + @Nested + class UpdateProduct { + + @DisplayName("중복되지 않는 상품명으로 수정하면, 200 OK와 수정된 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenUpdateIsValid() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest(NEW_PRODUCT_NAME, 20000, 50); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_PRODUCT.apply(product.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().name()).isEqualTo(NEW_PRODUCT_NAME), + () -> assertThat(response.getBody().data().price()).isEqualTo(20000) + ); + } + + @DisplayName("같은 브랜드의 중복된 상품명으로 수정하면, 409 CONFLICT 응답을 받는다.") + @Test + void returnsConflict_whenProductNameIsDuplicated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + productJpaRepository.save( + new Product(brand.getId(), NEW_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest(NEW_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_PRODUCT.apply(product1.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.CONFLICT.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 productId로 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenProductIdDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest(NEW_PRODUCT_NAME, VALID_PRICE, VALID_STOCK); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + String requestUrl = ENDPOINT_PRODUCT.apply(NOT_EXISTED_PRODUCT_ID); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.PUT, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{id}") + @Nested + class DeleteProduct { + + @DisplayName("존재하는 productId로 삭제하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenDeleteIsValid() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_PRODUCT.apply(product.getId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.DELETE, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("존재하지 않는 productId로 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenProductIdDoesNotExist() { + // arrange + HttpHeaders headers = createAdminHeaders(); + String requestUrl = ENDPOINT_PRODUCT.apply(NOT_EXISTED_PRODUCT_ID); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.DELETE, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} 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..2cf9d8f17 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -0,0 +1,160 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String VALID_PRODUCT_NAME = "나이키 에어맥스"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + private static final Long NOT_EXISTED_PRODUCT_ID = 999L; + + // BR-A03: 상품 조회는 공개 API - auth 헤더 불필요 + private static final String ENDPOINT_GET_LIST = "/api/v1/products"; + private static final Function ENDPOINT_PRODUCT = id -> "/api/v1/products/" + id; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProducts { + + @DisplayName("인증 없이 상품 목록을 조회하면, 200 OK와 상품 목록을 반환한다.") + @Test + void returnsProductList_withoutAuth() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET_LIST, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().products().get(0).name()).isEqualTo(VALID_PRODUCT_NAME) + ); + } + + @DisplayName("허용되지 않는 sort 값으로 요청하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void returnsBadRequest_whenSortIsInvalid() { + // arrange + String requestUrl = ENDPOINT_GET_LIST + "?sort=invalid"; + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.BAD_REQUEST.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api/v1/products/{id}") + @Nested + class GetProductDetails { + + @DisplayName("인증 없이 존재하는 productId로 요청하면, 200 OK와 상품 정보를 반환한다.") + @Test + void returnsProductDetails_withoutAuth() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + String requestUrl = ENDPOINT_PRODUCT.apply(product.getId()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_PRODUCT_NAME), + () -> assertThat(response.getBody().data().inStock()).isTrue() + ); + } + + @DisplayName("존재하지 않는 productId로 요청하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void returnsNotFound_whenProductIdDoesNotExist() { + // arrange + String requestUrl = ENDPOINT_PRODUCT.apply(NOT_EXISTED_PRODUCT_ID); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java index 69af52419..edc8a8e64 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -1,7 +1,6 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; -import com.loopers.interfaces.api.user.UserV1Dto; -import com.loopers.support.error.ErrorType; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; From 2c3d60f775cf5a6f57c257630ffb05b47d3d4e42 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 14:07:26 +0900 Subject: [PATCH 13/18] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductAdminFacade.java | 18 ++++- .../com/loopers/domain/product/Product.java | 1 + .../domain/product/ProductRepository.java | 6 ++ .../domain/product/ProductService.java | 51 ++++++++++++++ .../product/ProductJpaRepository.java | 24 +++++++ .../product/ProductRepositoryImpl.java | 26 +++++++ .../api/product/ProductSortType.java | 2 + .../interfaces/api/product/ProductV1Dto.java | 6 +- .../ProductServiceIntegrationTest.java | 67 +++++++++++++++++++ 9 files changed, 198 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index aedb871db..54b827105 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -2,12 +2,14 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; 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; @@ -18,6 +20,7 @@ public class ProductAdminFacade { private final ProductService productService; private final BrandService brandService; + private final LikeService likeService; // 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05) public ProductInfo register(ProductRegisterCommand command) { @@ -30,6 +33,7 @@ public ProductInfo register(ProductRegisterCommand command) { // 상품 상세 조회 public ProductInfo findById(Long id) { Product product = productService.findById(id); + // 상품의 brandId로 브랜드명 조회 String brandName = brandService.findById(product.getBrandId()).getName(); return ProductInfo.from(product, brandName); } @@ -37,8 +41,11 @@ public ProductInfo findById(Long id) { // 상품 목록 조회 (brandId 필터 선택) public Page findAll(Long brandId, Pageable pageable) { Page products = productService.findAll(brandId, pageable); + // 상품들의 brandId만 추출 List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + // brandId에 해당하는 브랜드명을 조회하면 Map으로 변환 Map brandNameMap = brandService.findNamesByIds(brandIds); + // 기존의 상품 List 각각의 product에 해당하는 브랜드명 매핑 return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))); } @@ -50,8 +57,17 @@ public ProductInfo update(ProductUpdateCommand command) { return ProductInfo.from(product, brandName); } - // 상품 삭제 + /** + * 상품 삭제 (US-P07) + * 좋아요(hard delete) → 상품(soft delete) 순서로 처리 + */ + @Transactional public void delete(Long id) { + // 상품 존재 확인 (없으면 NOT_FOUND 예외) + productService.findById(id); + // 좋아요 cascade hard delete + likeService.deleteAllByProductId(id); + // 상품 soft delete productService.delete(id); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 057a62314..fe090c059 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -63,6 +63,7 @@ public void decreaseStock(Quantity quantity) { this.stock = stock.decrease(quantity); } + // 주문 취소가 없어서 아직 쓰이진 않지만, 추후에 주문 취소 기능이 생기면 사용하게 될 예정 public void increaseStock(Quantity quantity) { this.stock = stock.increase(quantity); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 662adfdcc..e80f4f47b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -10,7 +11,12 @@ public interface ProductRepository { Optional findById(Long id); Page findAll(Pageable pageable); Page findAllByBrandId(Long brandId, Pageable pageable); + List findAllByIds(List ids); + List findAllByIdsForUpdate(List ids); + List findIdsByBrandId(Long brandId); boolean existsByBrandIdAndName(Long brandId, String name); boolean existsByBrandIdAndNameAndIdNot(Long brandId, String name, Long id); void deleteAllByBrandId(Long brandId); + void incrementLikeCount(Long productId); + void decrementLikeCount(Long productId); } 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 index 2fd06e5d0..ee87e8901 100644 --- 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 @@ -8,6 +8,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class ProductService { @@ -63,4 +67,51 @@ public void delete(Long id) { public void deleteAllByBrandId(Long brandId) { productRepository.deleteAllByBrandId(brandId); } + + // 브랜드 삭제 시 좋아요 cascade를 위한 상품 ID 목록 조회 + @Transactional(readOnly = true) + public List findIdsByBrandId(Long brandId) { + return productRepository.findIdsByBrandId(brandId); + } + + // ID 목록으로 상품 일괄 조회 (좋아요 목록에서 상품 정보 조합 시 사용) + @Transactional(readOnly = true) + public List findAllByIds(List ids) { + return productRepository.findAllByIds(ids); + } + + // 원자적 좋아요 수 증가 (US-L01) - DB 레벨 UPDATE로 동시성 보장 + @Transactional + public void increaseLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + // 원자적 좋아요 수 감소 (US-L02) - DB 레벨 UPDATE로 동시성 보장 + @Transactional + public void decreaseLikeCount(Long productId) { + productRepository.decrementLikeCount(productId); + } + + // 비관적 락으로 재고 확인 + 차감 원자적 수행 (US-O01, BR-O03, BR-O04) + // 동시 주문 시 SELECT FOR UPDATE로 행 잠금 → 재고 확인 후 즉시 차감 → TOCTOU 문제 방지 + @Transactional + public List verifyAndDecreaseStock(Map quantityByProductId) { + List productIds = new ArrayList<>(quantityByProductId.keySet()); + List products = productRepository.findAllByIdsForUpdate(productIds); + + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + + for (Product product : products) { + Quantity quantity = quantityByProductId.get(product.getId()); + if (!product.getStock().hasEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "상품의 재고가 부족합니다: " + product.getName()); + } + product.decreaseStock(quantity); + } + + return products; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index da3a33d89..213feef76 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,14 +1,18 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; 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.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.List; public interface ProductJpaRepository extends JpaRepository { @@ -16,11 +20,31 @@ public interface ProductJpaRepository extends JpaRepository { Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + List findAllByIdInAndDeletedAtIsNull(Collection ids); + boolean existsByBrandIdAndNameAndDeletedAtIsNull(Long brandId, String name); boolean existsByBrandIdAndNameAndIdNotAndDeletedAtIsNull(Long brandId, String name, Long id); + @Query("SELECT p.id FROM Product p WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + List findIdsByBrandIdAndDeletedAtIsNull(@Param("brandId") Long brandId); + @Modifying @Query("UPDATE Product p SET p.deletedAt = :deletedAt WHERE p.brandId = :brandId AND p.deletedAt IS NULL") void softDeleteAllByBrandId(@Param("brandId") Long brandId, @Param("deletedAt") ZonedDateTime deletedAt); + + // 비관적 락 - 재고 차감 전 행 잠금 (동시 주문 시 Lost Update 방지) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL") + List findAllByIdsForUpdate(@Param("ids") List ids); + + // 원자적 좋아요 수 증가 (read-modify-write 대신 DB 레벨 UPDATE) + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") + void incrementLikeCount(@Param("id") Long id); + + // 원자적 좋아요 수 감소 (likeCount > 0 조건으로 음수 방지) + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") + void decrementLikeCount(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 17548ca3f..2b0eb0862 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -36,6 +37,21 @@ public Page findAllByBrandId(Long brandId, Pageable pageable) { return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); } + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public List findAllByIdsForUpdate(List ids) { + return productJpaRepository.findAllByIdsForUpdate(ids); + } + + @Override + public List findIdsByBrandId(Long brandId) { + return productJpaRepository.findIdsByBrandIdAndDeletedAtIsNull(brandId); + } + @Override public boolean existsByBrandIdAndName(Long brandId, String name) { return productJpaRepository.existsByBrandIdAndNameAndDeletedAtIsNull(brandId, name); @@ -50,4 +66,14 @@ public boolean existsByBrandIdAndNameAndIdNot(Long brandId, String name, Long id public void deleteAllByBrandId(Long brandId) { productJpaRepository.softDeleteAllByBrandId(brandId, ZonedDateTime.now()); } + + @Override + public void incrementLikeCount(Long productId) { + productJpaRepository.incrementLikeCount(productId); + } + + @Override + public void decrementLikeCount(Long productId) { + productJpaRepository.decrementLikeCount(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java index e95822c01..34ac765b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java @@ -11,6 +11,7 @@ public enum ProductSortType { PRICE_ASC, // 가격 오름차순 LIKES_DESC; // 좋아요 많은 순 + // 허용되는 정렬 기준으로 전달 받았는지 확인 public static ProductSortType from(String value) { try { return valueOf(value.toUpperCase()); @@ -20,6 +21,7 @@ public static ProductSortType from(String value) { } } + // JPA에서 사용되는 Sort 객체로 변환 public Sort toSort() { return switch (this) { case PRICE_ASC -> Sort.by("price.amount").ascending(); 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 index 103f4611d..38ea9259c 100644 --- 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 @@ -13,7 +13,8 @@ public record ProductResponse( String brandName, String name, int price, - boolean inStock // BR-P05: 고객에게는 재고 여부(있음/없음)만 노출 + boolean inStock, // BR-P05: 고객에게는 재고 여부(있음/없음)만 노출 + int likeCount ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( @@ -22,7 +23,8 @@ public static ProductResponse from(ProductInfo info) { info.brandName(), info.name(), info.price(), - info.stock() > 0 + info.stock() > 0, + info.likeCount() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 2969c8e76..d7bb6f33c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -16,6 +16,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -261,4 +264,68 @@ void returnsFilteredProducts_whenBrandIdIsProvided() { assertThat(result.getContent().get(0).getBrandId()).isEqualTo(brand1.getId()); } } + + @DisplayName("재고 확인 및 차감 시 (비관적 락)") + @Nested + class VerifyAndDecreaseStock { + + @DisplayName("모든 상품이 존재하고 재고가 충분하면, 재고가 차감된 상품 목록을 반환한다.") + @Test + void decreasesStockAndReturnsProducts_whenAllStocksAreSufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, new Stock(10))); + Product product2 = productJpaRepository.save( + new Product(brand.getId(), NEW_PRODUCT_NAME, VALID_PRICE, new Stock(5))); + Map quantityMap = Map.of( + product1.getId(), new Quantity(2), + product2.getId(), new Quantity(3) + ); + + // act + List result = productService.verifyAndDecreaseStock(quantityMap); + + // assert - 반환값 확인 + assertThat(result).hasSize(2); + assertThat(result).extracting(Product::getId) + .containsExactlyInAnyOrder(product1.getId(), product2.getId()); + + // assert - DB에 재고 차감 반영 확인 + assertThat(productJpaRepository.findById(product1.getId()).orElseThrow().getStock().getQuantity()).isEqualTo(8); + assertThat(productJpaRepository.findById(product2.getId()).orElseThrow().getStock().getQuantity()).isEqualTo(2); + } + + @DisplayName("주문 항목에 존재하지 않는 상품이 포함되면, NOT_FOUND 에러가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + Long notExistedProductId = 999L; + Map quantityMap = Map.of(notExistedProductId, new Quantity(1)); + + // act + CoreException result = assertThrows(CoreException.class, + () -> productService.verifyAndDecreaseStock(quantityMap)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("재고가 부족한 상품이 포함되면, BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, VALID_PRICE, new Stock(1))); + Map quantityMap = Map.of(product.getId(), new Quantity(5)); // 재고(1) < 주문(5) + + // act + CoreException result = assertThrows(CoreException.class, + () -> productService.verifyAndDecreaseStock(quantityMap)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } From 13669bd070dda3a70ead14a40cb4e9e0c78d7a88 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 14:08:04 +0900 Subject: [PATCH 14/18] =?UTF-8?q?[feat]=20:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandAdminFacade.java | 18 +- .../loopers/application/like/LikeFacade.java | 86 +++++ .../loopers/application/like/LikeInfo.java | 34 ++ .../java/com/loopers/domain/like/Like.java | 63 ++++ .../loopers/domain/like/LikeRepository.java | 14 + .../com/loopers/domain/like/LikeService.java | 56 +++ .../like/LikeJpaRepository.java | 28 ++ .../like/LikeRepositoryImpl.java | 51 +++ .../interfaces/api/like/LikeV1ApiSpec.java | 20 ++ .../interfaces/api/like/LikeV1Controller.java | 53 +++ .../interfaces/api/like/LikeV1Dto.java | 49 +++ .../domain/brand/BrandServiceTest.java | 30 -- .../like/LikeServiceIntegrationTest.java | 233 ++++++++++++ .../com/loopers/domain/like/LikeTest.java | 55 +++ .../interfaces/api/like/LikeV1ApiE2ETest.java | 334 ++++++++++++++++++ 15 files changed, 1092 insertions(+), 32 deletions(-) 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/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/domain/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 99b75c9d8..7c93a4365 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -2,17 +2,22 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductService; 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; @RequiredArgsConstructor @Component public class BrandAdminFacade { private final BrandService brandService; private final ProductService productService; + private final LikeService likeService; // 브랜드 등록 public BrandInfo register(BrandRegisterCommand command){ @@ -37,11 +42,20 @@ public BrandInfo update(BrandUpdateCommand command){ return BrandInfo.from(brand); } - // 브랜드 삭제 - 상품 cascade soft delete 후 브랜드 삭제 (US-B06) - // Like/Cart cascade는 해당 도메인 구현 시 추가 예정 + /** + * 브랜드 삭제 (US-B06) + * 좋아요(hard delete) → 상품(soft delete) → 브랜드(soft delete) 순서로 처리 + */ + @Transactional public void delete(Long id){ brandService.findById(id); // 브랜드 존재 확인 + // 브랜드에 속한 상품 ID 조회 (좋아요 cascade 삭제 전 필요) + List productIds = productService.findIdsByBrandId(id); + // 좋아요 cascade hard delete + likeService.deleteAllByProductIds(productIds); + // 상품 cascade soft delete productService.deleteAllByBrandId(id); + // 브랜드 soft delete brandService.delete(id); } } 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..c185e29c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,86 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final BrandService brandService; + + /** + * 좋아요 등록 (US-L01) + * 상품 존재 확인 → 좋아요 등록 → 좋아요 수 증가 + */ + @Transactional + public LikeInfo create(Long userId, Long productId) { + // 상품 존재 확인 (없으면 NOT_FOUND 예외) + productService.findById(productId); + // 좋아요 등록 (중복이면 CONFLICT 예외) + Like like = likeService.create(userId, productId); + // 원자적 좋아요 수 증가 (DB 레벨 UPDATE - flushAutomatically로 Like INSERT 먼저 flush됨) + // clearAutomatically로 L1 캐시가 초기화되므로 아래 findById는 최신 likeCount를 반환함 + productService.increaseLikeCount(productId); + Product product = productService.findById(productId); + String brandName = brandService.findById(product.getBrandId()).getName(); + return LikeInfo.of(like, product, brandName); + } + + /** + * 좋아요 취소 (US-L02) + * 좋아요 취소 → 좋아요 수 감소 + */ + @Transactional + public void delete(Long userId, Long productId) { + // 좋아요 취소 (없으면 NOT_FOUND 예외) + likeService.delete(userId, productId); + // 좋아요 수 감소 + productService.decreaseLikeCount(productId); + } + + /** + * 좋아요한 상품 목록 조회 (US-L03) + * 회원의 좋아요 목록 조회 → 상품 정보 일괄 조회 → 조합 + */ + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + List likes = likeService.findAllByUserId(userId); + // 없으면 빈 목록 반환 + if (likes.isEmpty()) { + return List.of(); + } + + // 상품 ID들만 추출 + List productIds = likes.stream().map(Like::getProductId).toList(); + // 추출한 상품ID로 상품정보 조회 + Map productMap = productService.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + // 상품정보 Map에서 브랜드ID만 추출 + List brandIds = productMap.values().stream().map(Product::getBrandId).distinct().toList(); + // 브랜드ID로 브랜드명 조회 + Map brandNameMap = brandService.findNamesByIds(brandIds); + + return likes.stream() + // 삭제된 상품 등, 유효하지 않은 상품 필터링(방어 코드) + .filter(like -> productMap.containsKey(like.getProductId())) + .map(like -> { + Product product = productMap.get(like.getProductId()); + String brandName = brandNameMap.get(product.getBrandId()); + return LikeInfo.of(like, product, brandName); + }) + .toList(); + } +} 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..b27f19200 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,34 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long id, + Long userId, + Long productId, + String productName, + Long brandId, + String brandName, + int price, + boolean inStock, + int likeCount, + ZonedDateTime createdAt +) { + public static LikeInfo of(Like like, Product product, String brandName) { + return new LikeInfo( + like.getId(), + like.getUserId(), + product.getId(), + product.getName(), + product.getBrandId(), + brandName, + product.getPrice().getAmount(), + product.getStock().getQuantity() > 0, + product.getLikeCount(), + like.getCreatedAt() + ); + } +} 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..7a4a70480 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,63 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +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; + +/** + * 좋아요 엔티티. hard delete 정책이므로 BaseEntity를 상속하지 않는다. + * 생성 후 수정이 없으므로 updatedAt, deletedAt 불필요. + */ +@Entity +@Table(name = "likes", + uniqueConstraints = @UniqueConstraint( + name = "uk_likes_user_product", + columnNames = {"user_id", "product_id"} + )) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 좋아요를 누른 회원 ID + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + // 좋아요 대상 상품 ID + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다."); + } + this.userId = userId; + this.productId = productId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..df7ce466e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + Like save(Like like); + Optional findByUserIdAndProductId(Long userId, Long productId); + List findAllByUserId(Long userId); + boolean existsByUserIdAndProductId(Long userId, Long productId); + void deleteByUserIdAndProductId(Long userId, Long productId); + void deleteAllByProductId(Long productId); + void deleteAllByProductIds(List productIds); +} 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..76ce2cbad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,56 @@ +package com.loopers.domain.like; + +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.List; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + // 좋아요 등록 (BR-L01: 중복 방지) + @Transactional + public Like create(Long userId, Long productId) { + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + Like like = new Like(userId, productId); + return likeRepository.save(like); + } + + // 좋아요 취소 (BR-L02: 좋아요하지 않은 상품은 취소 불가) + @Transactional + public void delete(Long userId, Long productId) { + if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "좋아요 상태가 아닙니다."); + } + likeRepository.deleteByUserIdAndProductId(userId, productId); + } + + // 좋아요 목록 조회 (BR-L03: 자신의 좋아요 목록만 조회) + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + return likeRepository.findAllByUserId(userId); + } + + // 상품 삭제 시 cascade hard delete (US-P07) + @Transactional + public void deleteAllByProductId(Long productId) { + likeRepository.deleteAllByProductId(productId); + } + + // 브랜드 삭제 시 cascade hard delete (US-B06) + @Transactional + public void deleteAllByProductIds(List productIds) { + if (productIds.isEmpty()) { + return; + } + likeRepository.deleteAllByProductIds(productIds); + } +} 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..c244fb8e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +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.Collection; +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + void deleteByUserIdAndProductId(Long userId, Long productId); + + void deleteAllByProductId(Long productId); + + @Modifying + @Query("DELETE FROM Like l WHERE l.productId IN :productIds") + void deleteAllByProductIdIn(@Param("productIds") Collection productIds); +} 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..7cde0d23c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findAllByUserId(Long userId) { + return likeJpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId); + } + + @Override + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteByUserIdAndProductId(Long userId, Long productId) { + likeJpaRepository.deleteByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteAllByProductId(Long productId) { + likeJpaRepository.deleteAllByProductId(productId); + } + + @Override + public void deleteAllByProductIds(List productIds) { + likeJpaRepository.deleteAllByProductIdIn(productIds); + } +} 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..9c928d489 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "좋아요 API") +public interface LikeV1ApiSpec { + + @Operation(summary = "상품 좋아요 등록", description = "인증된 회원이 상품에 좋아요를 등록합니다.") + ApiResponse createLike(@LoginUser UserInfo loginUser, long productId); + + @Operation(summary = "상품 좋아요 취소", description = "인증된 회원이 상품의 좋아요를 취소합니다.") + ApiResponse deleteLike(@LoginUser UserInfo loginUser, long productId); + + @Operation(summary = "좋아요한 상품 목록 조회", description = "인증된 회원의 좋아요 목록을 조회합니다.") + ApiResponse getLikes(@LoginUser UserInfo loginUser); +} 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..fd6d92353 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/likes") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/{productId}") + @Override + public ApiResponse createLike( + @LoginUser UserInfo loginUser, + @PathVariable long productId) + { + LikeInfo like = likeFacade.create(loginUser.id(), productId); + return ApiResponse.success(LikeV1Dto.LikeResponse.from(like)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteLike( + @LoginUser UserInfo loginUser, + @PathVariable long productId) + { + likeFacade.delete(loginUser.id(), productId); + return ApiResponse.success(null); + } + + @GetMapping + @Override + public ApiResponse getLikes( + @LoginUser UserInfo loginUser) + { + List likes = likeFacade.findAllByUserId(loginUser.id()); + return ApiResponse.success(LikeV1Dto.LikedProductListResponse.from(likes)); + } +} 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..9c77456a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + /** + * 좋아요 등록 응답 + */ + public record LikeResponse( + Long likeId, + Long productId, + String productName, + Long brandId, + String brandName, + int price, + boolean inStock, + int likeCount, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.id(), + info.productId(), + info.productName(), + info.brandId(), + info.brandName(), + info.price(), + info.inStock(), + info.likeCount(), + info.createdAt() + ); + } + } + + /** + * 좋아요 목록 조회 응답 + */ + public record LikedProductListResponse(List likes) { + public static LikedProductListResponse from(List infos) { + return new LikedProductListResponse( + infos.stream().map(LikeResponse::from).toList() + ); + } + } +} 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 index bf810ef97..c99cd207c 100644 --- 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 @@ -152,21 +152,6 @@ void updateFailed_whenBrandNameIsNotUnique(){ assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); } - @DisplayName("존재하지 않는 brandId로 수정 시, 404에러를 반환한다.") - @Test - void updateFailed_whenBrandIdIsNotExists(){ - // stub - when(brandRepository.findById(NOT_EXISTED_BRAND_ID)).thenReturn(Optional.empty()); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - brandService.update(NOT_EXISTED_BRAND_ID, VALID_BRAND_NAME); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } @DisplayName("브랜드 삭제 시") @@ -189,20 +174,5 @@ void deleteSucceed_whenBrandIdIsValid(){ assertThat(brand.getDeletedAt()).isNotNull(); } - @DisplayName("존재하지 않는 brandId로 요청하면, 404에러 발생") - @Test - void deleteFailed_whenBrandIdIsNotExists(){ - // stub - when(brandRepository.findById(NOT_EXISTED_BRAND_ID)).thenReturn(Optional.empty()); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - brandService.delete(NOT_EXISTED_BRAND_ID); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - - } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..d0f9b4cc0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,233 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +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.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +public class LikeServiceIntegrationTest { + + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final String PRODUCT_NAME = "나이키 에어맥스"; + private static final Money VALID_PRICE = new Money(10000); + private static final Stock VALID_STOCK = new Stock(100); + + @Autowired + private LikeService likeService; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요 등록 시") + @Nested + class Create { + + @DisplayName("중복되지 않은 좋아요 등록에 성공한다.") + @Test + void createLikeSucceed_whenNotDuplicated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + Like result = likeService.create(USER_ID, product.getId()); + + // assert + assertThat(result.getId()).isPositive(); + assertThat(result.getUserId()).isEqualTo(USER_ID); + assertThat(result.getProductId()).isEqualTo(product.getId()); + + // DB에 실제 저장 확인 + assertThat(likeJpaRepository.existsByUserIdAndProductId(USER_ID, product.getId())).isTrue(); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면, CONFLICT 에러가 발생한다.") + @Test + void createLikeFail_whenAlreadyLiked() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product.getId())); + + // act + CoreException result = assertThrows(CoreException.class, + () -> likeService.create(USER_ID, product.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("좋아요 취소 시") + @Nested + class Delete { + + @DisplayName("존재하는 좋아요를 취소하면 hard delete된다.") + @Test + void deleteLikeSucceed_whenLikeExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product.getId())); + + // act + likeService.delete(USER_ID, product.getId()); + + // assert - hard delete이므로 DB에서 완전히 삭제됨 + assertThat(likeJpaRepository.existsByUserIdAndProductId(USER_ID, product.getId())).isFalse(); + } + + @DisplayName("좋아요하지 않은 상품을 취소하면, NOT_FOUND 에러가 발생한다.") + @Test + void deleteLikeFail_whenLikeNotExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + + // act + CoreException result = assertThrows(CoreException.class, + () -> likeService.delete(USER_ID, product.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("좋아요 목록 조회 시") + @Nested + class FindAllByUserId { + + @DisplayName("자신의 좋아요 목록만 반환한다.") + @Test + void returnsOnlyOwnLikes() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + Product product2 = productJpaRepository.save( + new Product(brand.getId(), "나이키 조던", VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product1.getId())); + likeJpaRepository.save(new Like(USER_ID, product2.getId())); + likeJpaRepository.save(new Like(OTHER_USER_ID, product1.getId())); + + // act + List result = likeService.findAllByUserId(USER_ID); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allMatch(like -> like.getUserId().equals(USER_ID)); + } + + @DisplayName("좋아요가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + // act + List result = likeService.findAllByUserId(USER_ID); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("상품 삭제 시 좋아요 cascade hard delete") + @Nested + class DeleteAllByProductId { + + @DisplayName("상품의 모든 좋아요가 hard delete된다.") + @Test + void deletesAllLikes_whenProductDeleted() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product.getId())); + likeJpaRepository.save(new Like(OTHER_USER_ID, product.getId())); + + // act + likeService.deleteAllByProductId(product.getId()); + + // assert + assertThat(likeJpaRepository.existsByUserIdAndProductId(USER_ID, product.getId())).isFalse(); + assertThat(likeJpaRepository.existsByUserIdAndProductId(OTHER_USER_ID, product.getId())).isFalse(); + } + } + + @DisplayName("브랜드 삭제 시 좋아요 cascade hard delete") + @Nested + class DeleteAllByProductIds { + + @DisplayName("여러 상품에 대한 좋아요가 모두 hard delete된다.") + @Test + void deletesAllLikes_whenMultipleProductsDeleted() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + Product product2 = productJpaRepository.save( + new Product(brand.getId(), "나이키 조던", VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product1.getId())); + likeJpaRepository.save(new Like(USER_ID, product2.getId())); + likeJpaRepository.save(new Like(OTHER_USER_ID, product1.getId())); + + // act + likeService.deleteAllByProductIds(List.of(product1.getId(), product2.getId())); + + // assert + assertThat(likeJpaRepository.findAllByUserIdOrderByCreatedAtDesc(USER_ID)).isEmpty(); + assertThat(likeJpaRepository.findAllByUserIdOrderByCreatedAtDesc(OTHER_USER_ID)).isEmpty(); + } + + @DisplayName("빈 ID 목록으로 호출하면 아무것도 삭제하지 않는다.") + @Test + void doesNothing_whenProductIdsIsEmpty() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, VALID_PRICE, VALID_STOCK)); + likeJpaRepository.save(new Like(USER_ID, product.getId())); + + // act + likeService.deleteAllByProductIds(List.of()); + + // assert - 좋아요 삭제되지 않음 + assertThat(likeJpaRepository.existsByUserIdAndProductId(USER_ID, product.getId())).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..1afdca58f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("Like 생성 시") + @Nested + class Create { + + @DisplayName("userId와 productId를 정상적으로 제공하면 Like가 생성된다.") + @Test + void createsLike_whenValidUserIdAndProductId() { + // arrange + Long userId = 1L; + Long productId = 2L; + + // act + Like like = new Like(userId, productId); + + // assert + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Like(null, 2L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Like(1L, null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..c41c9b39c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -0,0 +1,334 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.user.UserV1Dto; +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.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.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String VALID_LOGIN_ID = "likeuser1"; + private static final String VALID_PASSWORD = "like@1234"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private static final String SIGNUP_ENDPOINT = "/api/v1/users/signup"; + private static final Function ENDPOINT_CREATE_LIKE = productId -> "/api/v1/likes/" + productId; + private static final Function ENDPOINT_DELETE_LIKE = productId -> "/api/v1/likes/" + productId; + private static final String ENDPOINT_GET_LIKES = "/api/v1/likes"; + + private static final String VALID_PRODUCT_NAME = "나이키 에어맥스"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + private static final Long NOT_EXISTED_PRODUCT_ID = 999L; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final LikeJpaRepository likeJpaRepository; + private final UserJpaRepository userJpaRepository; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + LikeJpaRepository likeJpaRepository, + UserJpaRepository userJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.likeJpaRepository = likeJpaRepository; + this.userJpaRepository = userJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // AuthInterceptor 통과를 위한 유저 사전 등록 및 ID 반환 + Long signUpAndGetUserId() { + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, "좋아요유저", "1990-01-01", "like@test.com" + ); + testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {} + ); + return userJpaRepository.findByLoginId(VALID_LOGIN_ID) + .orElseThrow().getId(); + } + + HttpHeaders createUserHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + return headers; + } + + @DisplayName("POST /api/v1/likes/{productId}") + @Nested + class CreateLike { + + @DisplayName("인증된 회원이 존재하는 상품에 좋아요 등록하면, 200 OK와 좋아요 정보를 반환한다.") + @Test + void returnsLikeResponse_whenLikeCreatedSuccessfully() { + // arrange + signUpAndGetUserId(); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_LIKE.apply(product.getId()), + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().likeId()).isPositive(), + () -> assertThat(response.getBody().data().productId()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().productName()).isEqualTo(VALID_PRODUCT_NAME) + ); + } + + @DisplayName("인증 헤더 없이 좋아요 등록을 요청하면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenAuthHeaderMissing() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_LIKE.apply(product.getId()), + HttpMethod.POST, + HttpEntity.EMPTY, + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.UNAUTHORIZED.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면, 409 CONFLICT를 반환한다.") + @Test + void returnsConflict_whenAlreadyLiked() { + // arrange + Long userId = signUpAndGetUserId(); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + likeJpaRepository.save(new Like(userId, product.getId())); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_LIKE.apply(product.getId()), + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.CONFLICT.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductNotExist() { + // arrange + signUpAndGetUserId(); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_LIKE.apply(NOT_EXISTED_PRODUCT_ID), + HttpMethod.POST, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("DELETE /api/v1/likes/{productId}") + @Nested + class DeleteLike { + + @DisplayName("인증된 회원이 좋아요한 상품을 취소하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenLikeDeletedSuccessfully() { + // arrange + Long userId = signUpAndGetUserId(); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + likeJpaRepository.save(new Like(userId, product.getId())); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_DELETE_LIKE.apply(product.getId()), + HttpMethod.DELETE, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("좋아요하지 않은 상품을 취소하면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenLikeNotExist() { + // arrange + signUpAndGetUserId(); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_DELETE_LIKE.apply(product.getId()), + HttpMethod.DELETE, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api/v1/likes") + @Nested + class GetLikes { + + @DisplayName("인증된 회원이 자신의 좋아요 목록을 조회하면, 200 OK와 목록을 반환한다.") + @Test + void returnsLikedProductList_whenUserHasLikes() { + // arrange + Long userId = signUpAndGetUserId(); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + likeJpaRepository.save(new Like(userId, product.getId())); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_LIKES, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().likes()).hasSize(1), + () -> assertThat(response.getBody().data().likes().get(0).productName()).isEqualTo(VALID_PRODUCT_NAME) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + // arrange + signUpAndGetUserId(); + HttpHeaders headers = createUserHeaders(); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_LIKES, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().likes()).isEmpty() + ); + } + + } +} From 72c9cf2e7c99c537c99b2fa4fca7cf2745a51a6f Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 15:09:27 +0900 Subject: [PATCH 15/18] =?UTF-8?q?[fix]=20:=20=EA=B3=A0=EA=B0=9D=EC=97=90?= =?UTF-8?q?=EA=B2=8C=20=EC=83=81=ED=92=88=EC=A0=95=EB=B3=B4=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C,=20=EC=9E=AC=EA=B3=A0=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EB=B0=98=ED=99=98,=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20brandId=EB=8A=94=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20brand=20name=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/api/product/ProductV1Dto.java | 6 ++---- .../loopers/interfaces/api/product/ProductV1ApiE2ETest.java | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) 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 index 38ea9259c..3ca2722fd 100644 --- 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 @@ -9,21 +9,19 @@ public class ProductV1Dto { public record ProductResponse( long id, - long brandId, String brandName, String name, int price, - boolean inStock, // BR-P05: 고객에게는 재고 여부(있음/없음)만 노출 + int stock, int likeCount ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( info.id(), - info.brandId(), info.brandName(), info.name(), info.price(), - info.stock() > 0, + info.stock(), info.likeCount() ); } 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 index 2cf9d8f17..44809ee74 100644 --- 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 @@ -134,7 +134,7 @@ void returnsProductDetails_withoutAuth() { () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_PRODUCT_NAME), - () -> assertThat(response.getBody().data().inStock()).isTrue() + () -> assertThat(response.getBody().data().stock()).isEqualTo(VALID_STOCK) ); } From dc8e28986c0d655b2f904a180eff29e4bb3e26b5 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 15:10:13 +0900 Subject: [PATCH 16/18] =?UTF-8?q?[fix]=20:=20like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/like/LikeRepository.java | 2 -- .../com/loopers/infrastructure/like/LikeJpaRepository.java | 3 --- .../com/loopers/infrastructure/like/LikeRepositoryImpl.java | 6 ------ .../com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java | 1 + 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index df7ce466e..8053dc75e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -1,11 +1,9 @@ package com.loopers.domain.like; import java.util.List; -import java.util.Optional; public interface LikeRepository { Like save(Like like); - Optional findByUserIdAndProductId(Long userId, Long productId); List findAllByUserId(Long userId); boolean existsByUserIdAndProductId(Long userId, Long productId); void deleteByUserIdAndProductId(Long userId, Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index c244fb8e4..918d8c465 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -8,12 +8,9 @@ import java.util.Collection; import java.util.List; -import java.util.Optional; public interface LikeJpaRepository extends JpaRepository { - Optional findByUserIdAndProductId(Long userId, Long productId); - List findAllByUserIdOrderByCreatedAtDesc(Long userId); boolean existsByUserIdAndProductId(Long userId, Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 7cde0d23c..1987f5913 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.Optional; @RequiredArgsConstructor @Component @@ -19,11 +18,6 @@ public Like save(Like like) { return likeJpaRepository.save(like); } - @Override - public Optional findByUserIdAndProductId(Long userId, Long productId) { - return likeJpaRepository.findByUserIdAndProductId(userId, productId); - } - @Override public List findAllByUserId(Long userId) { return likeJpaRepository.findAllByUserIdOrderByCreatedAtDesc(userId); 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 index c41c9b39c..8c0bae54e 100644 --- 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 @@ -93,6 +93,7 @@ Long signUpAndGetUserId() { .orElseThrow().getId(); } + // 사용자 헤더 생성 메서드 HttpHeaders createUserHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); From 52a7e45758a1b443b0b35b6aa0bc5d2875833ef4 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 15:14:37 +0900 Subject: [PATCH 17/18] =?UTF-8?q?[feat]=20:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderAdminFacade.java | 34 ++ .../application/order/OrderCreateCommand.java | 13 + .../application/order/OrderFacade.java | 101 +++++ .../loopers/application/order/OrderInfo.java | 43 ++ .../java/com/loopers/domain/order/Order.java | 65 +++ .../com/loopers/domain/order/OrderItem.java | 91 ++++ .../loopers/domain/order/OrderRepository.java | 19 + .../loopers/domain/order/OrderService.java | 45 ++ .../order/OrderJpaRepository.java | 17 + .../order/OrderRepositoryImpl.java | 40 ++ .../api/order/OrderAdminV1ApiSpec.java | 24 + .../api/order/OrderAdminV1Controller.java | 34 ++ .../interfaces/api/order/OrderAdminV1Dto.java | 66 +++ .../interfaces/api/order/OrderV1ApiSpec.java | 30 ++ .../api/order/OrderV1Controller.java | 62 +++ .../interfaces/api/order/OrderV1Dto.java | 71 +++ .../order/OrderServiceIntegrationTest.java | 196 +++++++++ .../com/loopers/domain/order/OrderTest.java | 153 +++++++ .../api/order/OrderAdminV1ApiE2ETest.java | 186 ++++++++ .../api/order/OrderV1ApiE2ETest.java | 409 ++++++++++++++++++ 20 files changed, 1699 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.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/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/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/domain/order/OrderServiceIntegrationTest.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 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminFacade.java new file mode 100644 index 000000000..c3d04dd0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderAdminFacade.java @@ -0,0 +1,34 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +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; + +@RequiredArgsConstructor +@Component +public class OrderAdminFacade { + + private final OrderService orderService; + + /** + * 전체 주문 목록 조회 (US-O04, BR-O07) + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return orderService.findAll(pageable).map(OrderInfo::of); + } + + /** + * 단일 주문 상세 조회 (US-O05) + * 관리자는 소유권 확인 없이 모든 주문 조회 가능 + */ + @Transactional(readOnly = true) + public OrderInfo findById(Long orderId) { + Order order = orderService.findById(orderId); + return OrderInfo.of(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java new file mode 100644 index 000000000..b8303fe51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java @@ -0,0 +1,13 @@ +package com.loopers.application.order; + +import java.util.List; + +public record OrderCreateCommand( + List items +) { + + public record Item( + Long productId, + int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..f5ccef9d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,101 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.Quantity; +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.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final OrderService orderService; + private final ProductService productService; + private final BrandService brandService; + + /** + * 주문 생성 (US-O01) + * 상품 존재 확인 + 재고 검증 → 주문 생성(스냅샷) → 재고 차감 + */ + @Transactional + public OrderInfo create(Long userId, OrderCreateCommand command) { + Map quantityByProductId = command.items().stream() + .collect(Collectors.toMap( + OrderCreateCommand.Item::productId, + item -> new Quantity(item.quantity()) + )); + + // 비관적 락으로 재고 확인 + 차감 원자적 수행 (BR-O03, BR-O04) + // SELECT FOR UPDATE → 재고 검증 → decreaseStock (dirty checking) 순서로 TOCTOU 방지 + List products = productService.verifyAndDecreaseStock(quantityByProductId); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + // 브랜드명 일괄 조회 (스냅샷용) + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandNameMap = brandService.findNamesByIds(brandIds); + + // OrderItem 스냅샷 구성 (BR-O05) + List orderItems = command.items().stream() + .map(item -> { + Product product = productMap.get(item.productId()); + String brandName = brandNameMap.get(product.getBrandId()); + return new OrderItem( + product.getId(), + new Quantity(item.quantity()), + product.getName(), + brandName, + product.getPrice() + ); + }) + .toList(); + + // 주문 저장 + Order order = orderService.create(userId, orderItems); + + return OrderInfo.of(order); + } + + /** + * 회원 주문 상세 조회 (US-O03) + * 존재 확인 → 소유권 확인 (BR-O06) + */ + @Transactional(readOnly = true) + public OrderInfo findById(Long orderId, Long userId) { + Order order = orderService.findById(orderId); + if (!order.isOwnedBy(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다."); + } + return OrderInfo.of(order); + } + + /** + * 회원 주문 목록 조회 (US-O02) + * 기간 필터 + 소유권 제한 (BR-O06, BR-O08) + */ + @Transactional(readOnly = true) + public List findAllByUserId(Long userId, LocalDate startAt, LocalDate endAt) { + ZonedDateTime from = startAt.atStartOfDay(KST); + ZonedDateTime to = endAt.plusDays(1).atStartOfDay(KST); + return orderService.findAllByUserId(userId, from, to).stream() + .map(OrderInfo::of) + .toList(); + } +} 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..d0a381bd9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,43 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long id, + Long userId, + ZonedDateTime createdAt, + List items +) { + + public static OrderInfo of(Order order) { + List items = order.getOrderItems().stream() + .map(OrderItemInfo::of) + .toList(); + return new OrderInfo(order.getId(), order.getUserId(), order.getCreatedAt(), items); + } + + public record OrderItemInfo( + Long orderItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity + ) { + + public static OrderItemInfo of(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getBrandName(), + item.getPrice().getAmount(), + item.getQuantity().getValue() + ); + } + } +} 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..19804d331 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,65 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + // Order가 Aggregate Root, OrderItem은 Order를 통해서만 접근 (BR-O01: 최소 1개 항목) + // EAGER: OrderInfo 응답에 항상 OrderItem을 포함하므로 항상 함께 로딩 + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER) + @JoinColumn(name = "order_id", nullable = false) + private List orderItems = new ArrayList<>(); + + public Order(Long userId, List orderItems) { + validateUserId(userId); + validateOrderItems(orderItems); + + this.userId = userId; + this.orderItems = new ArrayList<>(orderItems); + } + + // BR-O06: 주문 소유자 확인 + public boolean isOwnedBy(Long userId) { + return this.userId.equals(userId); + } + + @Override + protected void guard() { + // userId만 컬럼 레벨에서 검증. orderItems(@OneToMany)는 JPA merge 시점에 + // 컬렉션 초기화 전에 guard()가 호출될 수 있으므로 생성자에서만 검증한다. + validateUserId(this.userId); + } + + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + } + + private void validateOrderItems(List orderItems) { + if (orderItems == null || orderItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 최소 1개 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..498c9e966 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,91 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "order_item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class OrderItem extends BaseEntity { + + // order_id FK - @JoinColumn으로 관리되므로 읽기 전용 + @Column(name = "order_id", insertable = false, updatable = false) + private Long orderId; + + // 원본 상품 추적용 FK + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + // 주문 시점 수량 (BR-O02: 1 이상) + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) + private Quantity quantity; + + // 주문 시점 스냅샷 (BR-O05) + @Column(name = "product_name", nullable = false, updatable = false) + private String productName; + + @Column(name = "brand_name", nullable = false, updatable = false) + private String brandName; + + @Embedded + @AttributeOverride(name = "amount", column = @Column(name = "price", nullable = false)) + private Money price; + + public OrderItem(Long productId, Quantity quantity, String productName, String brandName, Money price) { + validateProductId(productId); + validateProductName(productName); + validateBrandName(brandName); + validatePrice(price); + + this.productId = productId; + this.quantity = quantity; + this.productName = productName; + this.brandName = brandName; + this.price = price; + } + + @Override + protected void guard() { + validateProductId(this.productId); + validateProductName(this.productName); + validateBrandName(this.brandName); + validatePrice(this.price); + } + + private void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + } + + private void validateProductName(String productName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + } + + private void validateBrandName(String brandName) { + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + } + + private void validatePrice(Money price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 필수입니다."); + } + } +} 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..dd4558054 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime from, ZonedDateTime to); + + Page findAll(Pageable pageable); +} 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..baf32e458 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.order; + +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.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + + // 주문 생성 (US-O01) + @Transactional + public Order create(Long userId, List items) { + Order order = new Order(userId, items); + return orderRepository.save(order); + } + + // 주문 단건 조회 + @Transactional(readOnly = true) + public Order findById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); + } + + // 회원 주문 목록 조회 (BR-O06: 자신의 주문만, BR-O08: 기간 필터) + @Transactional(readOnly = true) + public List findAllByUserId(Long userId, ZonedDateTime from, ZonedDateTime to) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, from, to); + } + + // 전체 주문 목록 조회 (관리자, BR-O07) + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return orderRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..0b8a8e2f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull( + Long userId, ZonedDateTime from, ZonedDateTime to); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..cb265bf42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id) + .filter(order -> order.getDeletedAt() == null); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime from, ZonedDateTime to) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetweenAndDeletedAtIsNull(userId, from, to); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAllByDeletedAtIsNull(pageable); + } +} 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..6cca28578 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.order; + +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.web.bind.annotation.RequestParam; + +@Tag(name = "주문 관리자 API") +public interface OrderAdminV1ApiSpec { + + @Operation(summary = "전체 주문 목록 조회", description = "관리자가 전체 주문 목록을 페이지 단위로 조회합니다.") + ApiResponse getOrders( + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 당 나타낼 데이터 개수", example = "20") + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "단일 주문 상세 조회", description = "관리자가 특정 주문의 상세 내역을 조회합니다.") + ApiResponse getOrder( + @Parameter(description = "주문 ID") long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..9d58389e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderAdminFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller implements OrderAdminV1ApiSpec { + + private final OrderAdminFacade orderAdminFacade; + + @GetMapping + public ApiResponse getOrders( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) + { + return ApiResponse.success(OrderAdminV1Dto.OrderListResponse.from( + orderAdminFacade.findAll(PageRequest.of(page, size)))); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable long orderId) { + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from( + orderAdminFacade.findById(orderId))); + } +} 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..501193734 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import org.springframework.data.domain.Page; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + /** + * 관리자 주문 단건 응답 (상세 조회) + */ + public record OrderResponse( + Long orderId, + Long userId, + ZonedDateTime createdAt, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.userId(), + info.createdAt(), + info.items().stream().map(OrderItemResponse::from).toList() + ); + } + } + + public record OrderItemResponse( + Long orderItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.orderItemId(), + info.productId(), + info.productName(), + info.brandName(), + info.price(), + info.quantity() + ); + } + } + + /** + * 관리자 주문 목록 응답 (페이징) + */ + public record OrderListResponse( + List orders, + long totalCount, + int totalPages + ) { + public static OrderListResponse from(Page page) { + return new OrderListResponse( + page.getContent().stream().map(OrderResponse::from).toList(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} 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..b3f31259f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Tag(name = "주문 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "인증된 회원이 상품을 주문합니다.") + ApiResponse createOrder( + @LoginUser UserInfo loginUser, + OrderV1Dto.OrderCreateRequest request); + + @Operation(summary = "주문 목록 조회", description = "인증된 회원이 기간별 자신의 주문 목록을 조회합니다.") + ApiResponse getOrders( + @LoginUser UserInfo loginUser, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt); + + @Operation(summary = "주문 상세 조회", description = "인증된 회원이 자신의 특정 주문 상세를 조회합니다.") + ApiResponse getOrder( + @LoginUser UserInfo loginUser, + 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..a46e4872d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.support.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse createOrder( + @LoginUser UserInfo loginUser, + @RequestBody OrderV1Dto.OrderCreateRequest request) + { + List items = request.items().stream() + .map(item -> new OrderCreateCommand.Item(item.productId(), item.quantity())) + .toList(); + OrderCreateCommand command = new OrderCreateCommand(items); + return ApiResponse.success(OrderV1Dto.OrderResponse.from( + orderFacade.create(loginUser.id(), command))); + } + + @GetMapping + @Override + public ApiResponse getOrders( + @LoginUser UserInfo loginUser, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) + { + return ApiResponse.success(OrderV1Dto.OrderListResponse.from( + orderFacade.findAllByUserId(loginUser.id(), startAt, endAt))); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrder( + @LoginUser UserInfo loginUser, + @PathVariable long orderId) + { + return ApiResponse.success(OrderV1Dto.OrderResponse.from( + orderFacade.findById(orderId, loginUser.id()))); + } +} 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..164901ec8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + /** + * 주문 생성 요청 + */ + public record OrderCreateRequest( + List items + ) {} + + public record OrderItemRequest( + Long productId, + int quantity + ) {} + + /** + * 주문 단건 응답 (생성/상세 조회 공용) + */ + public record OrderResponse( + Long orderId, + ZonedDateTime createdAt, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.createdAt(), + info.items().stream().map(OrderItemResponse::from).toList() + ); + } + } + + public record OrderItemResponse( + Long orderItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.orderItemId(), + info.productId(), + info.productName(), + info.brandName(), + info.price(), + info.quantity() + ); + } + } + + /** + * 주문 목록 응답 + */ + public record OrderListResponse( + List orders + ) { + public static OrderListResponse from(List infos) { + return new OrderListResponse( + infos.stream().map(OrderResponse::from).toList() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..451896758 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,196 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Quantity; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +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.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +public class OrderServiceIntegrationTest { + + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long NOT_EXISTED_ORDER_ID = 999L; + private static final Money VALID_PRICE = new Money(10000); + private static final Stock VALID_STOCK = new Stock(100); + private static final Quantity ORDER_QUANTITY = new Quantity(2); + + @Autowired + private OrderService orderService; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderItem buildOrderItem(Product product, String brandName) { + return new OrderItem(product.getId(), ORDER_QUANTITY, product.getName(), brandName, product.getPrice()); + } + + @DisplayName("주문 생성 시") + @Nested + class Create { + + @DisplayName("정상적인 주문 항목으로 주문이 저장된다.") + @Test + void createsOrder_whenValidOrderItems() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), "나이키 에어맥스", VALID_PRICE, VALID_STOCK)); + List items = List.of(buildOrderItem(product, brand.getName())); + + // act + Order result = orderService.create(USER_ID, items); + + // assert + assertThat(result.getId()).isPositive(); + assertThat(result.getUserId()).isEqualTo(USER_ID); + assertThat(result.getOrderItems()).hasSize(1); + + // DB 저장 확인 + Order saved = orderJpaRepository.findById(result.getId()).orElseThrow(); + assertThat(saved.getOrderItems()).hasSize(1); + assertThat(saved.getOrderItems().get(0).getProductName()).isEqualTo("나이키 에어맥스"); + } + } + + @DisplayName("주문 단건 조회 시") + @Nested + class FindById { + + @DisplayName("존재하는 orderId로 조회하면 주문 정보를 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), "나이키 에어맥스", VALID_PRICE, VALID_STOCK)); + Order order = orderJpaRepository.save( + new Order(USER_ID, List.of(buildOrderItem(product, brand.getName())))); + + // act + Order result = orderService.findById(order.getId()); + + // assert + assertThat(result.getId()).isEqualTo(order.getId()); + assertThat(result.getUserId()).isEqualTo(USER_ID); + } + + @DisplayName("존재하지 않는 orderId로 조회하면 NOT_FOUND 에러가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, + () -> orderService.findById(NOT_EXISTED_ORDER_ID)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("회원 주문 목록 조회 시") + @Nested + class FindAllByUserId { + + @DisplayName("기간 내 자신의 주문만 반환한다.") + @Test + void returnsOnlyOwnOrdersWithinDateRange() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), "나이키 에어맥스", VALID_PRICE, VALID_STOCK)); + OrderItem item = buildOrderItem(product, brand.getName()); + + orderJpaRepository.save(new Order(USER_ID, List.of(item))); + orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item))); // 타인의 주문 + + ZonedDateTime from = ZonedDateTime.now().minusDays(1); + ZonedDateTime to = ZonedDateTime.now().plusDays(1); + + // act + List result = orderService.findAllByUserId(USER_ID, from, to); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getUserId()).isEqualTo(USER_ID); + } + + @DisplayName("기간 밖의 주문은 포함되지 않는다.") + @Test + void excludesOrdersOutsideDateRange() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), "나이키 에어맥스", VALID_PRICE, VALID_STOCK)); + orderJpaRepository.save(new Order(USER_ID, List.of(buildOrderItem(product, brand.getName())))); + + // 미래 기간으로 조회 + ZonedDateTime from = ZonedDateTime.now().plusDays(1); + ZonedDateTime to = ZonedDateTime.now().plusDays(2); + + // act + List result = orderService.findAllByUserId(USER_ID, from, to); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("전체 주문 목록 조회 (관리자) 시") + @Nested + class FindAll { + + @DisplayName("전체 주문을 페이지 단위로 반환한다.") + @Test + void returnsAllOrdersWithPaging() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), "나이키 에어맥스", VALID_PRICE, VALID_STOCK)); + OrderItem item = buildOrderItem(product, brand.getName()); + + orderJpaRepository.save(new Order(USER_ID, List.of(item))); + orderJpaRepository.save(new Order(OTHER_USER_ID, List.of(item))); + + // act + Page result = orderService.findAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(2); + } + } +} 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..b0e2ea67f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,153 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Quantity; +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.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long PRODUCT_ID = 10L; + private static final Quantity VALID_QUANTITY = new Quantity(2); + private static final Money VALID_PRICE = new Money(10000); + private static final String PRODUCT_NAME = "나이키 에어맥스"; + private static final String BRAND_NAME = "나이키"; + + private OrderItem validOrderItem() { + return new OrderItem(PRODUCT_ID, VALID_QUANTITY, PRODUCT_NAME, BRAND_NAME, VALID_PRICE); + } + + @DisplayName("Order 생성 시") + @Nested + class Create { + + @DisplayName("정상적인 userId와 orderItems로 Order가 생성된다.") + @Test + void createsOrder_whenValidParameters() { + // act + Order order = new Order(USER_ID, List.of(validOrderItem())); + + // assert + assertThat(order.getUserId()).isEqualTo(USER_ID); + assertThat(order.getOrderItems()).hasSize(1); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Order(null, List.of(validOrderItem()))); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("orderItems가 비어있으면 BAD_REQUEST 에러가 발생한다. (BR-O01)") + @Test + void throwsBadRequest_whenOrderItemsIsEmpty() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new Order(USER_ID, List.of())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Order.isOwnedBy() 시") + @Nested + class IsOwnedBy { + + @DisplayName("주문 소유자의 userId이면 true를 반환한다.") + @Test + void returnsTrue_whenUserIdMatches() { + // arrange + Order order = new Order(USER_ID, List.of(validOrderItem())); + + // act & assert + assertThat(order.isOwnedBy(USER_ID)).isTrue(); + } + + @DisplayName("주문 소유자가 아닌 userId이면 false를 반환한다. (BR-O06)") + @Test + void returnsFalse_whenUserIdDoesNotMatch() { + // arrange + Order order = new Order(USER_ID, List.of(validOrderItem())); + + // act & assert + assertThat(order.isOwnedBy(OTHER_USER_ID)).isFalse(); + } + } + + @DisplayName("OrderItem 생성 시") + @Nested + class CreateOrderItem { + + @DisplayName("정상적인 파라미터로 OrderItem이 생성된다.") + @Test + void createsOrderItem_whenValidParameters() { + // act + OrderItem item = new OrderItem(PRODUCT_ID, VALID_QUANTITY, PRODUCT_NAME, BRAND_NAME, VALID_PRICE); + + // assert + assertThat(item.getProductId()).isEqualTo(PRODUCT_ID); + assertThat(item.getProductName()).isEqualTo(PRODUCT_NAME); + assertThat(item.getBrandName()).isEqualTo(BRAND_NAME); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(null, VALID_QUANTITY, PRODUCT_NAME, BRAND_NAME, VALID_PRICE)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 blank이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(PRODUCT_ID, VALID_QUANTITY, " ", BRAND_NAME, VALID_PRICE)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("brandName이 blank이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(PRODUCT_ID, VALID_QUANTITY, PRODUCT_NAME, " ", VALID_PRICE)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 null이면 BAD_REQUEST 에러가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + // act + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(PRODUCT_ID, VALID_QUANTITY, PRODUCT_NAME, BRAND_NAME, null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..82b03f4cf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderAdminV1ApiE2ETest.java @@ -0,0 +1,186 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Quantity; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +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.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.ResponseEntity; + +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderAdminV1ApiE2ETest { + + private static final String HEADER_ADMIN_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private static final String ENDPOINT_GET_ORDERS = "/api-admin/v1/orders"; + private static final Function ENDPOINT_GET_ORDER = id -> "/api-admin/v1/orders/" + id; + + private static final Long USER_ID = 1L; + private static final Long NOT_EXISTED_ORDER_ID = 999L; + private static final String PRODUCT_NAME = "나이키 에어맥스"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 100; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final OrderJpaRepository orderJpaRepository; + + @Autowired + public OrderAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + OrderJpaRepository orderJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.orderJpaRepository = orderJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); + return headers; + } + + Order createSavedOrder(Product product, String brandName) { + return orderJpaRepository.save(new Order(USER_ID, + List.of(new OrderItem(product.getId(), new Quantity(1), product.getName(), brandName, product.getPrice())))); + } + + @DisplayName("GET /api-admin/v1/orders") + @Nested + class GetOrders { + + @DisplayName("관리자가 전체 주문 목록을 페이지 단위로 조회하면, 200 OK와 주문 목록을 반환한다.") + @Test + void returnsAllOrders_whenAdminRequests() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + createSavedOrder(product, brand.getName()); + createSavedOrder(product, brand.getName()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDERS + "?page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().orders()).hasSize(2) + ); + } + + @DisplayName("관리자 인증 헤더 없이 조회하면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenAdminHeaderMissing() { + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDERS + "?page=0&size=20", + HttpMethod.GET, + HttpEntity.EMPTY, + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(ErrorType.UNAUTHORIZED.getStatus()); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") + @Nested + class GetOrder { + + @DisplayName("관리자가 주문 상세를 조회하면, 200 OK와 주문 상세 정보를 반환한다.") + @Test + void returnsOrderDetail_whenAdminRequests() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + Order order = createSavedOrder(product, brand.getName()); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDER.apply(order.getId()), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.getId()), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @DisplayName("존재하지 않는 주문을 조회하면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDER.apply(NOT_EXISTED_ORDER_ID), + HttpMethod.GET, + new HttpEntity<>(createAdminHeaders()), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()); + } + } +} 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..68108f8d4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -0,0 +1,409 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Quantity; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.user.UserV1Dto; +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.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.ResponseEntity; + +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String VALID_LOGIN_ID = "orderuser1"; + private static final String VALID_PASSWORD = "order@1234"; + private static final String OTHER_LOGIN_ID = "otheruser1"; + private static final String OTHER_PASSWORD = "other@1234"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private static final String SIGNUP_ENDPOINT = "/api/v1/users/signup"; + private static final String ENDPOINT_CREATE_ORDER = "/api/v1/orders"; + private static final String ENDPOINT_GET_ORDERS = "/api/v1/orders"; + private static final Function ENDPOINT_GET_ORDER = id -> "/api/v1/orders/" + id; + + private static final String VALID_PRODUCT_NAME = "나이키 에어맥스"; + private static final int VALID_PRICE = 10000; + private static final int VALID_STOCK = 10; + private static final Long NOT_EXISTED_ORDER_ID = 999L; + private static final Long NOT_EXISTED_PRODUCT_ID = 999L; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final OrderJpaRepository orderJpaRepository; + private final UserJpaRepository userJpaRepository; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + OrderJpaRepository orderJpaRepository, + UserJpaRepository userJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.orderJpaRepository = orderJpaRepository; + this.userJpaRepository = userJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + Long signUpAndGetUserId(String loginId, String password, String name) { + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + loginId, password, name, "1990-01-01", loginId + "@test.com" + ); + testRestTemplate.exchange( + SIGNUP_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {} + ); + return userJpaRepository.findByLoginId(loginId).orElseThrow().getId(); + } + + HttpHeaders createUserHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + HttpHeaders createUserHeaders() { + return createUserHeaders(VALID_LOGIN_ID, VALID_PASSWORD); + } + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + + @DisplayName("인증된 회원이 존재하는 상품으로 주문하면, 200 OK와 주문 정보를 반환한다.") + @Test + void returnsOrderResponse_whenOrderCreatedSuccessfully() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + OrderV1Dto.OrderCreateRequest request = new OrderV1Dto.OrderCreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 2)) + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_ORDER, HttpMethod.POST, + new HttpEntity<>(request, createUserHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().orderId()).isPositive(), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo(VALID_PRODUCT_NAME) + ); + } + + @DisplayName("인증 헤더 없이 주문하면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenAuthHeaderMissing() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + OrderV1Dto.OrderCreateRequest request = new OrderV1Dto.OrderCreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 1)) + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_ORDER, HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.UNAUTHORIZED.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 상품으로 주문하면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductNotExist() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + OrderV1Dto.OrderCreateRequest request = new OrderV1Dto.OrderCreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(NOT_EXISTED_PRODUCT_ID, 1)) + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_ORDER, HttpMethod.POST, + new HttpEntity<>(request, createUserHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("재고가 부족한 상품으로 주문하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenStockIsInsufficient() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(1))); + OrderV1Dto.OrderCreateRequest request = new OrderV1Dto.OrderCreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 5)) // 재고(1) < 주문(5) + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CREATE_ORDER, HttpMethod.POST, + new HttpEntity<>(request, createUserHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.BAD_REQUEST.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("주문 후 상품 재고가 주문 수량만큼 차감된다.") + @Test + void decreasesStock_afterOrderCreated() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + int orderQuantity = 3; + OrderV1Dto.OrderCreateRequest request = new OrderV1Dto.OrderCreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), orderQuantity)) + ); + + // act + testRestTemplate.exchange( + ENDPOINT_CREATE_ORDER, HttpMethod.POST, + new HttpEntity<>(request, createUserHeaders()), + new ParameterizedTypeReference>() {} + ); + + // assert + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStock().getQuantity()).isEqualTo(VALID_STOCK - orderQuantity); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class GetOrders { + + @DisplayName("기간을 지정하여 자신의 주문 목록을 조회하면, 200 OK와 주문 목록을 반환한다.") + @Test + void returnsOrderList_whenUserHasOrders() { + // arrange + Long userId = signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + orderJpaRepository.save(new Order(userId, + List.of(new OrderItem(product.getId(), new Quantity(1), VALID_PRODUCT_NAME, brand.getName(), product.getPrice())))); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDERS + "?startAt=2020-01-01&endAt=2099-12-31", + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orders()).hasSize(1) + ); + } + + @DisplayName("startAt 파라미터 없이 조회하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenStartAtIsMissing() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDERS + "?endAt=2099-12-31", + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(ErrorType.BAD_REQUEST.getStatus()); + } + + @DisplayName("endAt 파라미터 없이 조회하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenEndAtIsMissing() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDERS + "?startAt=2020-01-01", + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(ErrorType.BAD_REQUEST.getStatus()); + } + + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetOrder { + + @DisplayName("자신의 주문을 상세 조회하면, 200 OK와 주문 상세 정보를 반환한다.") + @Test + void returnsOrderDetail_whenOrderBelongsToUser() { + // arrange + Long userId = signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + Order order = orderJpaRepository.save(new Order(userId, + List.of(new OrderItem(product.getId(), new Quantity(1), VALID_PRODUCT_NAME, brand.getName(), product.getPrice())))); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDER.apply(order.getId()), + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), + responseType + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.getId()), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @DisplayName("타인의 주문을 조회하면, 404 NOT_FOUND를 반환한다. (BR-O06)") + @Test + void returnsNotFound_whenOrderBelongsToOtherUser() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + Long otherUserId = signUpAndGetUserId(OTHER_LOGIN_ID, OTHER_PASSWORD, "타인유저"); + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save( + new Product(brand.getId(), VALID_PRODUCT_NAME, new Money(VALID_PRICE), new Stock(VALID_STOCK))); + Order otherOrder = orderJpaRepository.save(new Order(otherUserId, + List.of(new OrderItem(product.getId(), new Quantity(1), VALID_PRODUCT_NAME, brand.getName(), product.getPrice())))); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDER.apply(otherOrder.getId()), + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), // VALID_LOGIN_ID로 접근 + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("존재하지 않는 주문을 조회하면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + // arrange + signUpAndGetUserId(VALID_LOGIN_ID, VALID_PASSWORD, "주문유저"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_GET_ORDER.apply(NOT_EXISTED_ORDER_ID), + HttpMethod.GET, + new HttpEntity<>(createUserHeaders()), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(ErrorType.NOT_FOUND.getStatus()); + } + + } +} From ab7b9ecde829eaf9a1d0c008a38f54ca2349de8d Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 27 Feb 2026 15:40:22 +0900 Subject: [PATCH 18/18] =?UTF-8?q?[docs]=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/order/OrderFacade.java | 1 + .../main/java/com/loopers/domain/product/ProductService.java | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index f5ccef9d9..fd379e8e7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -36,6 +36,7 @@ public class OrderFacade { */ @Transactional public OrderInfo create(Long userId, OrderCreateCommand command) { + // Quantity VO 통해서 수량 검증 Map quantityByProductId = command.items().stream() .collect(Collectors.toMap( OrderCreateCommand.Item::productId, 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 index ee87e8901..3cc10acbb 100644 --- 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 @@ -93,7 +93,7 @@ public void decreaseLikeCount(Long productId) { } // 비관적 락으로 재고 확인 + 차감 원자적 수행 (US-O01, BR-O03, BR-O04) - // 동시 주문 시 SELECT FOR UPDATE로 행 잠금 → 재고 확인 후 즉시 차감 → TOCTOU 문제 방지 + // 동시 주문 시 SELECT FOR UPDATE로 행 잠금 → 재고 확인 후 즉시 차감 → TOCTOU(Time Of Check To Time Of Use) 문제 방지 @Transactional public List verifyAndDecreaseStock(Map quantityByProductId) { List productIds = new ArrayList<>(quantityByProductId.keySet()); @@ -109,6 +109,7 @@ public List verifyAndDecreaseStock(Map quantityByProduc throw new CoreException(ErrorType.BAD_REQUEST, "상품의 재고가 부족합니다: " + product.getName()); } + // 더티 체킹 product.decreaseStock(quantity); }