diff --git a/.claude/commands/impl-pass-tests.md b/.claude/commands/impl-pass-tests.md new file mode 100644 index 000000000..cd1c7e4b9 --- /dev/null +++ b/.claude/commands/impl-pass-tests.md @@ -0,0 +1,28 @@ +# 테스트 통과 구현코드 작성 + +대상 테스트/기능: $ARGUMENTS + +모델 고정: `openai/gpt-5.3-codex-spark` + +이미 존재하는 테스트 또는 방금 작성한 테스트를 통과하도록 구현코드를 작성해주세요. + +## 반드시 따를 규칙 +1. 아키텍처/코딩 규칙 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/AGENTS.md` + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/coding-style.md` +2. 테스트 정책 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` +3. 오버엔지니어링 금지, 최소 변경으로 통과 +4. 민감정보 마스킹/인증 단일화/Locale.ROOT/중복키 409 변환 규칙 준수 + +## 작업 절차 +1. 실패 테스트 기준으로 필요한 구현 범위만 식별 +2. 구현 코드 수정 +3. 필요한 경우 테스트 fixture만 최소 보정 +4. 관련 테스트만 우선 실행 +5. 통과 확인 후 변경 요약 + +## 출력 +- 수정 파일 목록 +- 테스트 통과 결과 요약 +- 남은 리스크/추가 필요 테스트 diff --git a/.claude/commands/test-write.md b/.claude/commands/test-write.md new file mode 100644 index 000000000..f22e3c5db --- /dev/null +++ b/.claude/commands/test-write.md @@ -0,0 +1,33 @@ +# 테스트코드 작성 (실행 없음) + +대상 기능/파일: $ARGUMENTS + +모델 고정: `openai/gpt-5.3-codex-spark` + +이 프로젝트 규칙에 맞춰 테스트코드만 작성해주세요. 테스트 실행은 하지 않습니다. + +## 반드시 따를 규칙 +1. `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` 우선 적용 +2. 도메인별 레시피 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/README.md` + - 관련 도메인 레시피(`user.md`, `order.md`, `product-like.md`) 선택 적용 +3. 상태 검증 우선, 단위 테스트는 classist(mock/stub only) +4. 레이어 정책 준수: + - Domain/Domain Service: 단위 테스트 + - Application Service/Controller: 통합 테스트 코드 형태 + +## 작성 절차 +1. 기존 테스트 패턴과 네이밍을 먼저 분석 +2. 대상 코드의 happy/unhappy/boundary 케이스 도출 +3. 필수 회귀 항목 반영: + - toString 마스킹 + - raw/encoded 분리 + - 저장 시점 중복키 예외 + - 이름 마스킹 1/2/3글자 +4. 테스트 파일 생성/수정 +5. 변경 요약 출력 (무엇을 왜 추가했는지) + +## 출력 +- 수정된 테스트 파일 경로 목록 +- 각 테스트의 의도(1줄) +- 남은 테스트 갭(있으면) diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md new file mode 120000 index 000000000..5f97203eb --- /dev/null +++ b/.claude/rules/coding-style.md @@ -0,0 +1 @@ +../../docs/ai-rules/coding-style.md \ No newline at end of file diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md new file mode 120000 index 000000000..0148ee622 --- /dev/null +++ b/.claude/rules/git-workflow.md @@ -0,0 +1 @@ +../../docs/ai-rules/git-workflow.md \ No newline at end of file diff --git a/.claude/rules/performance.md b/.claude/rules/performance.md new file mode 120000 index 000000000..7292b31ba --- /dev/null +++ b/.claude/rules/performance.md @@ -0,0 +1 @@ +../../docs/ai-rules/performance.md \ No newline at end of file diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 120000 index 000000000..d314dc58d --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1 @@ +../../docs/ai-rules/security.md \ No newline at end of file diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 120000 index 000000000..d8de4ce78 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1 @@ +../../docs/ai-rules/testing.md \ No newline at end of file diff --git a/.claude/skills/design-review/SKILL.md b/.claude/skills/design-review/SKILL.md new file mode 100644 index 000000000..26f876636 --- /dev/null +++ b/.claude/skills/design-review/SKILL.md @@ -0,0 +1,88 @@ +--- +name: design-review +description: 설계 리뷰 스킬. 기존 설계 문서나 코드를 입력하면 나쁜 설계를 감지하고, 위험 요소와 대안 모델을 제시합니다. "설계 리뷰", "설계 검토", "디자인 리뷰" 등의 요청에 사용합니다. +disable-model-invocation: true +argument-hint: [설계 문서 경로 또는 리뷰 대상 설명] +--- + +# Design Review Skill + +당신은 **설계 리뷰어**입니다. 좋은 설계를 만드는 것보다 **나쁜 설계를 빠르게 감지**하는 데 집중합니다. + +## 핵심 원칙 +- 정답을 제시하지 않는다. **위험 요소**와 **선택지**를 제시한다. +- 감지된 문제마다 반드시 **대안 모델**을 함께 제시한다. +- 오버엔지니어링 방향의 대안은 제안하지 않는다. + +## 톤 and 스타일 +- "이게 틀렸다"가 아닌 **"이 부분이 위험하다, 왜냐하면"** 톤을 유지한다 +- 코드 리뷰가 아닌 **설계 리뷰**에 집중한다 (의도, 책임, 경계, 의존 방향) +- 칭찬할 부분이 있으면 짧게 언급하되, 리뷰 본문에 집중한다 + +## 입력 판별 + +$ARGUMENTS가 파일 경로(`.md`, `.java` 등 확장자 포함 또는 `/`, `./`로 시작)인 경우, Read 도구로 해당 파일을 먼저 읽는다. 디렉토리 경로인 경우 하위 파일을 탐색하여 설계 문서를 찾는다. 그 외에는 입력된 텍스트를 리뷰 대상 설명으로 사용한다. + +## 리뷰 프로세스 + +### Step 1: 설계 의도 파악 + +리뷰 대상을 읽고, 먼저 설계자의 의도를 정리한다: +- 이 설계가 해결하려는 문제는 무엇인가? +- 핵심 도메인 객체와 책임은 무엇인가? +- 어떤 트레이드오프를 선택했는가? + +### Step 2: 나쁜 설계 감지 + +`../requirements-analysis/references/review-checklist.md`를 Read 도구로 읽어서 체크리스트로 사용한다. 리뷰 대상 문서 유형에 맞는 공통 체크리스트 and 추가 체크 항목을 적용한다. + +### Step 3: Gemini 교차 검증 + +`mcp__gemini__gemini_cli`를 호출한다: + +``` +다음 설계를 리뷰해줘: +[설계 내용 전달] + +다음 관점에서 위험 요소를 찾아줘: +1. 책임이 잘못 배치된 곳 (최대 3개) +2. 확장/변경 시 깨지기 쉬운 구조 (최대 2개) +3. 내가 놓치고 있는 위험이 있는가? (최대 2개) + +단, 현재 MVP 범위 내에서만 판단해줘. 미래 확장성을 이유로 복잡도를 높이는 제안은 하지 마. +``` + +### Step 4: 종합 리뷰 보고서 + +Gemini 의견과 자체 분석을 종합하여 다음 형식으로 출력한다: + +``` +## 설계 리뷰 결과 + +### 설계 의도 요약 +> [설계자가 해결하려는 문제 한 문장] + +### 위험 요소 + +#### 1. [위험 요소명] +- **위치**: [어디에서 발견되었는가] +- **위험**: [왜 위험한가] +- **영향**: [방치하면 어떤 문제가 생기는가] +- **대안 A**: [선택지] → [트레이드오프] +- **대안 B**: [선택지] → [트레이드오프] + +#### 2. [위험 요소명] +(동일 구조) + +### 잘된 부분 +- [간단히 언급] + +### 결론 +- 즉시 수정 권장: [항목] +- 주의 관찰 필요: [항목] +- 현재 수준 유지: [항목] +``` + +### Step 5: 사용자 논의 + +AskUserQuestion으로 각 위험 요소에 대해 사용자의 판단을 구한다. 대안 선택지와 영향도를 함께 제시한다. diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..ae066132c --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,289 @@ +--- +name: requirements-analysis +description: Lean 요구사항 분석 및 기술 설계 스킬. 프로젝트 개요를 입력하면 Gemini(기획자)와 Claude(아키텍트)가 3자 대화로 요구사항을 검증하고, 오버엔지니어링 없는 명세서와 Mermaid 다이어그램을 생성합니다. "요구사항 분석", "프로젝트 설계", "스펙 작성" 등의 요청에 사용합니다. +disable-model-invocation: true +argument-hint: [프로젝트 개요 또는 파일경로] +--- + +# Lean Hybrid Requirements Architect + +당신은 **Lean Architect**입니다. 오버엔지니어링 철저히 배제, 핵심 비즈니스 가치를 달성 위한 최소 복잡도만 허용하는 요구사항 분석가이자 기술 설계자. + +## 핵심 원칙 +우리는 "가장 단순한 것이 가장 훌륭하다(Simple is Best)"와 "YAGNI(You Ain't Gonna Need It)" 원칙을 따릅니다. +모든 분석과 설계는 오버엔지니어링을 철저히 배제하고, 핵심 비즈니스 가치를 달성하기 위한 최소한의 복잡도만 허용합니다. + +## 참여자 역할 + +| 역할 | 담당 | 책임 | +| -------------------------- | ---------------- | --------------------------------------------------- | +| **사용자** | 프로젝트 발주자 | 최종 의사결정 | +| **Gemini** (MCP 자동 호출) | 날카로운 기획자 | 비즈니스 로직 허점 탐색, MVP 필수 여부 자체 검증 | +| **Claude** (당신) | Lean Architect | 오버엔지니어링 필터링, 회의 주재, 모델링 | + +## 톤 and 스타일 +- 강의처럼 설명하지 말고 **설계 리뷰 톤**을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공한다 +- 코드보다 **의도, 책임, 경계**를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 +- 추측하거나 알아서 결정하지 않는다. 애매한 것은 드러낸다 + +## 프로세스 + +**반드시 Phase 1부터 순서대로 진행한다. 각 Phase 완료 시 사용자에게 확인받고 다음으로 넘어간다.** + +--- + +### Phase 1: 프로젝트 착수 및 범위 설정 + +사용자가 프로젝트 개요($ARGUMENTS)를 입력하면: + +**입력 판별:** $ARGUMENTS가 파일 경로(`.md`, `.txt` 등 확장자 포함 또는 `/`, `./`로 시작)인 경우, Read 도구로 해당 파일을 먼저 읽어서 내용을 프로젝트 개요로 사용한다. 그 외에는 입력된 텍스트를 그대로 프로젝트 개요로 사용한다. + +#### 1-1. 문제로 재해석 + +요구사항 문장을 정리하는 데서 끝내지 않는다. 다음 세 관점으로 분리해서 재해석한다: + +| 관점 | 질문 | +| ------------------ | ------------------------------------------------------- | +| **사용자 관점** | 사용자가 겪는 불편/문제는 무엇인가? | +| **비즈니스 관점** | 이 문제를 해결하면 비즈니스에 어떤 가치가 생기는가? | +| **시스템 관점** | 현재 시스템이 이 문제를 해결하지 못하는 이유는 무엇인가? | + +> 예시: "주문 실패 시 결제를 취소한다" +> → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +#### 1-2. 핵심 가치 정의 and 도메인 파악 + +- 이 프로젝트의 핵심 가치를 **한 문장**으로 정의한다 +- 핵심 도메인 영역을 3-7개로 식별한다 +- 불필요해 보이는 기능은 과감히 Scope-out을 제안한다 + +#### 1-3. Gemini 검증 + +`mcp__gemini__gemini_cli`를 호출하여 다음을 요청한다: + +``` +이 프로젝트 개요를 검토해줘: +[프로젝트 개요 and 문제 재해석 결과 전달] + +다음을 분석해줘: +1. 이 프로젝트의 핵심 가치는 무엇인가? (한 문장) +2. MVP에 반드시 필요한 핵심 기능 3-5개 +3. MVP에서 빼도 되는 기능 (과도한 것들) +4. 놓치고 있는 비즈니스 리스크가 있는가? +5. 유사 서비스/경쟁사는 이 문제를 어떻게 해결하고 있는가? (1-2개만) +6. 경쟁사 대비 우리가 빼도 되는 기능이 있는가? + +단, 기능 제안 시 "이것이 MVP에 필수적인가?"를 반드시 자체 검증해줘. +경쟁사 분석은 기능 추가 근거가 아닌, MVP 다이어트 근거로 활용해줘. +``` + +#### 1-4. Claude 종합 + +Gemini 의견을 기술적으로 검토하되, 오버엔지니어링이면 경고를 띄우고 더 단순한 대안을 제시한다. + +**출력 형식:** +``` +## 문제 정의 +- 사용자 관점: [문제] +- 비즈니스 관점: [문제] +- 시스템 관점: [문제] + +## 프로젝트 핵심 가치 +> [한 문장 정의] + +## 핵심 도메인 (Scope-in) +1. [도메인1] - [한줄 설명] +2. [도메인2] - [한줄 설명] + +## Scope-out (제외 항목) +- [기능A] - 이유: [왜 불필요한지] + +## Gemini 의견 요약 +- [핵심 지적사항] + +## Claude 판단 +- [수용/거부 및 이유] +``` + +AskUserQuestion으로 사용자에게 확인받은 후 Phase 2로 진행한다. + +--- + +### Phase 2: 3자 대화 - 기능별 검증 및 다이어트 + +Scope-in된 각 도메인/기능에 대해 **반복(Loop)** 수행: + +#### 2-1. 애매함을 드러낸다 + +각 기능에서 결정되지 않은 부분을 명시적으로 나열한다. **다음 유형의 질문을 반드시 포함한다:** + +| 질문 유형 | 목적 | 예시 | +| -------------- | -------------------------------------------- | --------------------------------------------- | +| **정책 질문** | 기준 시점, 성공/실패 조건, 예외 처리 규칙 | "결제 실패 시 재시도 횟수는?" | +| **경계 질문** | 어디까지가 한 책임인가, 어디서 분리되는가 | "주문 취소는 주문 도메인? 결제 도메인?" | +| **확장 질문** | 나중에 바뀔 가능성이 있는가 | "할인 정책이 추후 복잡해질 가능성은?" | + +#### 2-2. 개발자 답변이 쉬운 형태로 질문한다 + +AskUserQuestion으로 질문하되, 선택지가 있는 경우 **옵션 and 영향도**를 함께 제시한다. + +> 형식 예시: +> - 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +> - 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +질문은 우선순위를 가진다 (중요한 것부터). + +#### 2-3. Gemini 검증 + +`mcp__gemini__gemini_cli`를 호출한다: + +``` +다음 기능의 엣지 케이스와 위험 요소를 찾아줘. +단, 구현 비용이 너무 비싸지 않은 현실적인 범위 내에서만 지적해줘. + +기능: [기능명] +설명: [사용자가 설명한 내용] +프로젝트 맥락: [핵심 가치 한 문장] + +다음 형식으로 답해줘: +1. 핵심 엣지 케이스 (최대 3개, 구현 비용 낮은 것만) +2. 비즈니스 위험 요소 (현실적인 것만) +3. "이건 오버엔지니어링이다" 싶은 것이 있으면 명시 +``` + +#### 2-4. Claude 종합 and 필터링 + +Gemini 의견을 **"구현 복잡도 vs 가치"** 기준으로 필터링한다: +- 복잡도 높고 가치 낮음 → **오버엔지니어링 경고** and 단순한 대안 제시 +- 복잡도 낮고 가치 높음 → 수용 +- 복잡도 높고 가치 높음 → 사용자에게 트레이드오프 설명 후 결정 요청 + +대화 과정에서 등장하는 도메인 용어는 즉시 합의한다. (예: 상품을 Product로 부를지 Item으로 부를지) 확정된 용어는 명세서 용어 사전에 반영한다. + +#### 2-5. 행위 and 비즈니스 규칙 추출 + +User Story 작성 전, 합의된 시나리오에서 다음을 분리한다: + +1. **행위 추출**: 유저 시나리오에서 핵심 "행위"를 뽑아낸다 +2. **비즈니스 규칙 추출**: 비즈니스 규칙을 통해 "제약"과 "판단 기준"을 뽑아낸다 + +#### 2-6. User Story and AC 정리 + +합의된 내용을 정리한다: + +``` +### US-[번호]: [Story 제목] +**As a** [사용자 역할] +**I want to** [원하는 기능] +**So that** [비즈니스 가치] + +**수용 기준 (AC):** +- [ ] AC1: Given [조건], When [행동], Then [결과] +- [ ] AC2: Given [에러 조건], When [행동], Then [에러 처리] +- [ ] AC3: Given [엣지 케이스], When [행동], Then [결과] + +**비기능 요구사항:** (해당 시에만) +- 성능: [측정 가능한 기준] +- 보안: [구체적 요구사항] +``` + +모든 도메인 검증이 완료되면 AskUserQuestion으로 사용자 확인 후 Phase 3으로 진행한다. + +--- + +### Phase 3: 명세서 도출 + +최종 합의된 내용만 문서화한다. "나중에 필요할지도 모르는 기능"은 모두 배제한다. + +#### 3-1. 개념 모델 정의 + +바로 기술 얘기로 들어가지 않는다. 먼저 다음을 정의한다: + +| 항목 | 설명 | +| -------------------- | -------------------- | +| **액터** | 사용자, 외부 시스템 | +| **핵심 도메인** | 비즈니스 핵심 영역 | +| **보조/외부 시스템** | 연동 대상, 인프라 | + +이 단계는 "구현"이 아니라 **설계 사고 정렬**이 목적이다. + +#### 3-2. Scope-out 검증 + +명세서 작성 전, Scope-out 항목이 설계에 실수로 포함되지 않았는지 검증한다. Phase 1에서 제외한 항목이 User Story나 AC에 슬며시 들어가 있으면 제거한다. + +#### 3-3. 명세서 파일 저장 + +`references/spec-template.md`를 Read 도구로 읽어서 템플릿으로 사용한다. 프로젝트 경로 내 `docs/design/01-requirements.md`에 저장한다. + +#### 3-4. 명세서 설계 검토 (승인 루프) + +작성된 명세서를 `references/review-checklist.md`의 체크리스트로 검토한다. 검토 결과와 함께 사용자에게 제시한다. + +**승인 루프:** 사용자가 승인할 때까지 다음을 반복한다: +1. 설계 검토 결과 and 수정 제안 제시 +2. AskUserQuestion으로 사용자 피드백 수집 +3. 피드백 반영하여 명세서 수정 +4. 재검토 → 다시 1번으로 + +사용자가 승인하면 Phase 4로 진행한다. + +--- + +### Phase 4: Lean 기술 모델링 (Mermaid.js) + +`references/diagram-guide.md`를 Read 도구로 읽어서 다이어그램 작성 규칙, 파일 구조, 저장 경로를 따른다. + +각 다이어그램은 **작성 → 설계 검토 → 승인** 루프를 거친다. 사용자가 승인할 때까지 수정/재검토를 반복한다. + +#### 4-1. Sequence Diagram 작성 and 검토 + +시퀀스 다이어그램을 작성하고 `docs/design/02-sequence-diagrams.md`에 저장한다. `references/review-checklist.md`로 검토 후 사용자 승인까지 루프한다. + +#### 4-2. Class Diagram 작성 and 검토 + +클래스 다이어그램을 작성하고 `docs/design/03-class-diagram.md`에 저장한다. 검토 후 사용자 승인까지 루프한다. + +#### 4-3. ERD 작성 and 검토 + +ERD를 작성하고 `docs/design/04-erd.md`에 저장한다. 검토 후 사용자 승인까지 루프한다. + +#### 4-4. 설계 리스크 총정리 + +모든 다이어그램 승인 후, 전체 설계에서 발견된 잠재 리스크를 총정리한다. + +--- + +## 오버엔지니어링 경고 기준 + +다음에 해당하면 **즉시 경고**하고 단순한 대안을 제시한다: + +- 현재 MVP에 필요 없는 추상화 레이어 추가 +- 사용자 수 100명 미만인데 마이크로서비스 아키텍처 제안 +- 3개 이상의 디자인 패턴 중첩 +- "확장성을 위해"라는 이유로 복잡도 증가 +- 실제 트래픽 데이터 없이 캐싱/큐/샤딩 도입 +- 아직 발생하지 않은 문제에 대한 사전 방어 코드 + +``` +⚠️ 오버엔지니어링 경고 +- 제안: [복잡한 제안] +- 문제: [왜 과도한지] +- 대안: [더 단순한 방법] +``` + +## 인터뷰 스타일 + +- AskUserQuestion으로 1-2개 질문씩 집중해서 묻는다 +- 추상적 질문 대신 구체적 시나리오로 묻는다 +- 사용자가 불확실해하면 **선택지 and 영향도**를 함께 제시한다 +- Gemini와 의견이 다를 때는 트레이드오프를 명확히 보여주고 사용자가 결정한다 +- 모든 Phase 전환 시 사용자 확인을 받는다 + +## Phase 전환 규칙 + +- Phase 1 완료 → AskUserQuestion: "범위 설정이 완료되었습니다. Phase 2(기능별 검증)로 진행할까요?" +- Phase 2 완료 → AskUserQuestion: "모든 기능 검증이 완료되었습니다. Phase 3(명세서 작성)으로 진행할까요?" +- Phase 3 완료 → AskUserQuestion: "명세서가 완성되었습니다. Phase 4(기술 모델링)로 진행할까요?" +- Phase 4 완료 → 설계 리스크 총정리 and 최종 요약 출력 diff --git a/.claude/skills/requirements-analysis/references/diagram-guide.md b/.claude/skills/requirements-analysis/references/diagram-guide.md new file mode 100644 index 000000000..88c4d47bd --- /dev/null +++ b/.claude/skills/requirements-analysis/references/diagram-guide.md @@ -0,0 +1,197 @@ +# 다이어그램 작성 가이드 + +## 저장 구조 + +모든 산출물은 `docs/design/` 디렉토리에 번호 파일로 저장한다. + +``` +docs/design/ + 01-requirements.md + 02-sequence-diagrams.md + 03-class-diagram.md + 04-erd.md +``` + +## 2단계 원칙 + +모든 다이어그램은 반드시 이 순서를 따른다: + +1. **다이어그램**: Mermaid 문법으로 작성한다 +2. **해석**: "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다 + +## 생성 순서 + +도메인별로 Sequence → Class를 완성한 뒤, 마지막에 ERD를 작성한다. + +--- + +## Sequence Diagram + +- **"1 Feature = 1 Diagram"** 원칙을 엄격히 준수한다 +- 전체 로직을 하나의 다이어그램에 그리지 않는다 +- 각 시퀀스는 Happy Path and 주요 Unhappy Path만 표현한다 +- **검증 목적**: 책임 분리, 호출 순서, 트랜잭션 경계 확인 + +### 화살표(벡터) 표기 규칙 + +| 화살표 | 의미 | Mermaid 문법 | +|--------|------|--------------| +| `──▶` (실선+채움) | 동기 호출 | `->>` | +| `──>` (실선+열림) | 비동기 호출 | `->` | +| `- - ▶` (점선) | 응답/반환 | `-->>` | + +- 동기 호출은 `->>`, 응답은 `-->>` 로 구분한다 +- 비동기 이벤트는 `-)` 문법을 사용한다 + +### 액티베이션 바 규칙 + +- 액티베이션 바는 **처리 중인 상태**를 명시적으로 표현할 때만 사용한다 +- `+`로 활성화 시작, `-`로 활성화 종료: `A->>+B: 요청` / `B-->>-A: 응답` +- 단순 흐름에서는 생략해도 무방하다 (간결함 우선) +- **호출 규칙**: Service 간 직접 호출 금지. Facade → Service → Repository 단방향만 허용. Service는 자기 도메인의 Repository만 접근한다. Facade는 Repository를 직접 접근하지 않는다. +- **메시지 표기 규칙**: API 호출(Client → Controller)은 HTTP 메서드 + 경로를 그대로 표기하고, 내부 호출(Facade ↔ Service ↔ Repository)은 정확한 함수명 대신 한글 설명으로 작성한다. (예: `재고 차감 요청`, `주문 + 주문항목 저장`) 비개발자도 흐름을 이해할 수 있도록 한다. +- **트랜잭션 위치 규칙**: `@Transactional`은 Domain Service에만 위치한다. Facade에는 절대 `@Transactional`을 두지 않는다. 크로스 도메인 쓰기 시 각 Service가 자기 도메인 내에서 개별 트랜잭션을 관리하고, 실패 시 Facade에서 보상 로직을 처리한다. +- **표기 금지**: 시퀀스 다이어그램에서는 `@Transactional`을 note/메시지/participant 어디에도 표기하지 않는다. SQL 쿼리 상세, Repository participant도 금지한다. Facade ↔ Service 레벨까지만 표현한다. +- **자주 겪는 실수**: (1) 세부 흐름 과다로 시퀀스 복잡화 (2) Service만 호출, 도메인 객체 메시지 없음 (3) 추상 수준이 낮아 구현과 괴리 → 유지보수 불가 +- 파일명: `docs/design/02-sequence-diagrams.md` (모든 시퀀스를 하나의 파일에 작성) +- 최소 2개 이상의 시퀀스 다이어그램을 작성한다 + +**파일 구조:** + +```markdown +# 시퀀스 다이어그램 + +## [기능명 1] + +### 시퀀스 다이어그램 + +\```mermaid +sequenceDiagram + ... +\``` + +### 핵심 포인트 +- [봐야 할 포인트 1] +- [봐야 할 포인트 2] + +### 설계 리스크 +- [잠재적 위험 and 선택지] + +--- + +## [기능명 2] +(동일 구조 반복) +``` + +--- + +## Class Diagram (Core Domain Only) + +- Controller, DTO, Utils 등 나열하지 않는다 +- **핵심 도메인 엔티티(Entity)와 주요 관계(Association)**만 그린다 +- 속성/메서드도 핵심적인 것만 표기한다 +- **검증 목적**: 도메인 책임, 의존 방향, 응집도 확인 +- 파일명: `docs/design/03-class-diagram.md` + +**자주 겪는 실수 (반드시 검토):** +- 모든 필드를 객체로 표현하려다 지나친 복잡도를 만드는 것 +- 도메인 책임 없이 Service에 모든 로직이 집중되는 것 +- VO를 테이블처럼 다루려는 시도 (예: Price를 별도 DB로 설계) + +**파일 구조:** + +```markdown +# 클래스 다이어그램 + +## 클래스 다이어그램 + +\```mermaid +classDiagram + ... +\``` + +## 핵심 포인트 +- [봐야 할 포인트 1] +- [봐야 할 포인트 2] + +## 설계 리스크 +- [잠재적 위험 and 선택지] +``` + +--- + +## ERD (논리적 관계만) + +- **물리적 FK 제약 조건을 표현하지 않는다** +- 엔티티 간 **논리적 연결(비즈니스 관계)**만 표현한다 +- 관계선은 카디널리티만 표시하고, FK 컬럼을 명시하지 않는다 +- 각 엔티티는 핵심 비즈니스 속성만 나열한다 (`id`, `created_at`, `*_id` 등 생략) +- 관계 라벨은 비즈니스 언어로 작성한다 (예: "주문한다", "포함한다") +- **검증 목적**: 영속성 구조, 관계의 주인, 정규화 여부 확인 +- 파일명: `docs/design/04-erd.md` + +**ERD 작성 규칙:** +1. 테이블이 아닌 **엔티티(비즈니스 개념)** 관점으로 작성한다 +2. `id`, `created_at`, `updated_at`, `*_id`(FK) 컬럼은 생략한다 +3. 카디널리티(1:N, N:M)는 정확히 표현한다 +4. N:M 관계는 조인 테이블을 명시적으로 표현한다 (직접 N:M 연결 금지) +5. 각 엔티티별 **삭제 전략(Soft/Hard Delete)** 을 명시한다 + +### 삭제 전략 표기 + +ERD 작성 후, 각 엔티티의 삭제 전략을 테이블로 정리한다: + +| 엔티티 | 삭제 전략 | 이유 | +|--------|-----------|------| +| User | Soft Delete | 주문 이력 참조 유지 필요 | +| Order | Soft Delete | 감사/정산 목적 보관 | +| Cart | Hard Delete | 임시 데이터, 보관 불필요 | + +**판단 기준:** +- **Soft Delete**: 이력 추적, 감사, 다른 엔티티에서 참조, 복구 가능성 필요 시 +- **Hard Delete**: 임시 데이터, 개인정보 완전 삭제 요구, 참조 없음 + +**파일 구조:** + +```markdown +# ERD (논리적 관계) + +## ERD + +\```mermaid +erDiagram + USER { + string email + string nickname + string role + } + ORDER { + date orderDate + string status + int totalAmount + } + + USER ||--o{ ORDER : "주문한다" +\``` + +## 핵심 포인트 +- [봐야 할 포인트 1] +- [봐야 할 포인트 2] + +## 설계 리스크 +- [잠재적 위험 and 선택지] +``` + +--- + +## 설계 리스크 총정리 + +모든 다이어그램이 완성되면, 전체 설계에서 발견된 잠재 리스크를 한 곳에 모아 정리한다: + +| 리스크 유형 | 확인 항목 | +| ------------------ | ------------------------------------------------------ | +| 트랜잭션 비대화 | 하나의 트랜잭션에 너무 많은 책임이 몰려있지 않은가? | +| 도메인 간 결합도 | 도메인 경계를 넘는 직접 참조가 있는가? | +| 정책 변경 영향 | 비즈니스 규칙이 바뀌면 어디까지 영향을 받는가? | + +해결책은 정답처럼 말하지 않고 **선택지**로 제시한다. diff --git a/.claude/skills/requirements-analysis/references/review-checklist.md b/.claude/skills/requirements-analysis/references/review-checklist.md new file mode 100644 index 000000000..732a47e58 --- /dev/null +++ b/.claude/skills/requirements-analysis/references/review-checklist.md @@ -0,0 +1,69 @@ +# 설계 검토 체크리스트 + +각 산출물 작성 후, 해당하는 체크리스트로 검토한다. 위험 요소 발견 시 **대안을 선택지로 제시**하고 사용자 판단을 구한다. + +--- + +## 공통 체크리스트 + +| 감지 항목 | 확인 질문 | +| ---------------------- | -------------------------------------------------------- | +| **책임 누수** | 한 객체가 너무 많은 책임을 지고 있지 않은가? | +| **도메인 간 결합** | 도메인 경계를 넘는 직접 참조가 있는가? | +| **과도한 추상화** | 현재 필요 없는 추상화 레이어가 있는가? | +| **Scope-out 혼입** | 제외해야 할 기능이 설계에 포함되어 있는가? | +| **정책 변경 취약점** | 비즈니스 규칙이 바뀌면 어디까지 영향을 받는가? | + +--- + +## 명세서 (01-requirements.md) 추가 체크 + +- User Story의 행위가 구체적인가? (애매한 동사 없는지) +- AC의 Given/When/Then이 테스트로 변환 가능한가? +- Scope-out 항목이 User Story에 슬며시 포함되지 않았는가? +- 용어가 일관되게 사용되고 있는가? (유비쿼터스 언어) + +--- + +## 시퀀스 다이어그램 (02-sequence-diagrams.md) 추가 체크 + +- 1 Feature = 1 Diagram 원칙을 지키고 있는가? +- Happy Path 외에 주요 Unhappy Path도 포함하는가? +- 트랜잭션 경계가 명확한가? +- 호출 순서가 책임 분리와 일치하는가? + +--- + +## 클래스 다이어그램 (03-class-diagram.md) 추가 체크 + +- 핵심 도메인 엔티티만 포함하는가? (Controller, DTO, Utils 제외) +- 도메인 객체에 비즈니스 로직이 있는가? (빈약한 도메인 아닌지) +- 의존 방향이 올바른가? +- VO를 테이블처럼 다루고 있지 않은가? + +--- + +## ERD (04-erd.md) 추가 체크 + +- 논리적 관계만 표현하는가? (물리적 FK 제약 없는지) +- `id`, `created_at`, `*_id` 등 공통 컬럼이 생략되었는가? +- N:M 관계가 조인 테이블로 표현되었는가? +- 스냅샷이 필요한 곳에 참조만 걸려 있지 않은가? +- 카디널리티가 정확한가? + +--- + +## 검토 결과 출력 형식 + +``` +### 검토 결과 + +**통과 항목:** [문제 없는 항목 수] / [전체 항목 수] + +**위험 요소:** +1. [항목명] - [왜 위험한가] + - 대안 A: [선택지] → [트레이드오프] + - 대안 B: [선택지] → [트레이드오프] + +**수정 불필요:** 승인 후 다음 단계로 진행 +``` diff --git a/.claude/skills/requirements-analysis/references/spec-template.md b/.claude/skills/requirements-analysis/references/spec-template.md new file mode 100644 index 000000000..d307cdc10 --- /dev/null +++ b/.claude/skills/requirements-analysis/references/spec-template.md @@ -0,0 +1,47 @@ +# 요구사항 명세서 템플릿 + +아래 구조를 따라 프로젝트 경로 내 `docs/design/01-requirements.md`에 저장한다. + +```markdown +# [프로젝트명] 요구사항 명세서 + +> 생성일: [날짜] +> 핵심 가치: [한 문장] + +## 1. 문제 정의 +- 사용자 관점: [문제] +- 비즈니스 관점: [문제] +- 시스템 관점: [문제] + +## 2. 개념 모델 +### 액터 +- [사용자 유형] +- [외부 시스템] + +### 핵심 도메인 +- [도메인] - [책임] + +### 보조/외부 시스템 +- [시스템] - [역할] + +## 3. User Stories + +### 도메인 1: [이름] +[해당 도메인의 US 목록] + +### 도메인 2: [이름] +[해당 도메인의 US 목록] + +## 4. 비기능 요구사항 (전체) +- 성능: [기준] +- 보안: [기준] + +## 5. Scope-out (명시적 제외) +[제외된 기능 and 이유] + +## 6. 미결정 사항 +[Phase 2에서 드러났지만 아직 결정되지 않은 항목] + +## 7. 용어 사전 (필요 시) +[도메인 특화 용어 정리] +``` diff --git a/.opencode/commands/impl-pass-tests.md b/.opencode/commands/impl-pass-tests.md new file mode 100644 index 000000000..cd1c7e4b9 --- /dev/null +++ b/.opencode/commands/impl-pass-tests.md @@ -0,0 +1,28 @@ +# 테스트 통과 구현코드 작성 + +대상 테스트/기능: $ARGUMENTS + +모델 고정: `openai/gpt-5.3-codex-spark` + +이미 존재하는 테스트 또는 방금 작성한 테스트를 통과하도록 구현코드를 작성해주세요. + +## 반드시 따를 규칙 +1. 아키텍처/코딩 규칙 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/AGENTS.md` + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/coding-style.md` +2. 테스트 정책 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` +3. 오버엔지니어링 금지, 최소 변경으로 통과 +4. 민감정보 마스킹/인증 단일화/Locale.ROOT/중복키 409 변환 규칙 준수 + +## 작업 절차 +1. 실패 테스트 기준으로 필요한 구현 범위만 식별 +2. 구현 코드 수정 +3. 필요한 경우 테스트 fixture만 최소 보정 +4. 관련 테스트만 우선 실행 +5. 통과 확인 후 변경 요약 + +## 출력 +- 수정 파일 목록 +- 테스트 통과 결과 요약 +- 남은 리스크/추가 필요 테스트 diff --git a/.opencode/commands/plan.md b/.opencode/commands/plan.md new file mode 100644 index 000000000..6153a65c4 --- /dev/null +++ b/.opencode/commands/plan.md @@ -0,0 +1,22 @@ +# 구현/테스트 계획 수립 (코드 수정 없음) + +작업 대상: $ARGUMENTS + +모델 고정: `openai/gpt-5.3-codex` + +이 작업은 **계획만** 작성합니다. 코드/테스트 파일은 수정하지 않습니다. + +## 반드시 따를 규칙 +1. 아키텍처/코딩 규칙: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/AGENTS.md` + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/coding-style.md` +2. 테스트 전략: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` +3. 퍼사드는 여러 Application Service 조합이 필요한 경우에만 사용 +4. 계획은 구현 가능한 최소 단위(작업 순서 + 파일 단위 변경점)로 작성 + +## 출력 형식 +1. 작업 목표 요약 (3줄 이내) +2. 구현 단계 (순서형) +3. 테스트 단계 (단위/통합/E2E 구분) +4. 리스크/의사결정 포인트 diff --git a/.opencode/commands/test-write.md b/.opencode/commands/test-write.md new file mode 100644 index 000000000..f22e3c5db --- /dev/null +++ b/.opencode/commands/test-write.md @@ -0,0 +1,33 @@ +# 테스트코드 작성 (실행 없음) + +대상 기능/파일: $ARGUMENTS + +모델 고정: `openai/gpt-5.3-codex-spark` + +이 프로젝트 규칙에 맞춰 테스트코드만 작성해주세요. 테스트 실행은 하지 않습니다. + +## 반드시 따를 규칙 +1. `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` 우선 적용 +2. 도메인별 레시피 적용: + - `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/README.md` + - 관련 도메인 레시피(`user.md`, `order.md`, `product-like.md`) 선택 적용 +3. 상태 검증 우선, 단위 테스트는 classist(mock/stub only) +4. 레이어 정책 준수: + - Domain/Domain Service: 단위 테스트 + - Application Service/Controller: 통합 테스트 코드 형태 + +## 작성 절차 +1. 기존 테스트 패턴과 네이밍을 먼저 분석 +2. 대상 코드의 happy/unhappy/boundary 케이스 도출 +3. 필수 회귀 항목 반영: + - toString 마스킹 + - raw/encoded 분리 + - 저장 시점 중복키 예외 + - 이름 마스킹 1/2/3글자 +4. 테스트 파일 생성/수정 +5. 변경 요약 출력 (무엇을 왜 추가했는지) + +## 출력 +- 수정된 테스트 파일 경로 목록 +- 각 테스트의 의도(1줄) +- 남은 테스트 갭(있으면) diff --git a/.opencode/commands/worktree-create.md b/.opencode/commands/worktree-create.md new file mode 100644 index 000000000..c87540e74 --- /dev/null +++ b/.opencode/commands/worktree-create.md @@ -0,0 +1,17 @@ +# 도메인 작업용 워크트리 생성 + +입력: `$ARGUMENTS` (예: `user`, `order`) + +아래 명령을 **실제로 실행**하세요. + +## 실행 명령 +```bash +git checkout shAn-kor +git pull origin shAn-kor +git worktree add ../wt-$ARGUMENTS feature/$ARGUMENTS +``` + +## 출력 +- 실행한 명령 목록 +- 생성된 worktree 경로 +- 현재 worktree 목록 (`git worktree list`) diff --git a/.opencode/commands/worktree-merge.md b/.opencode/commands/worktree-merge.md new file mode 100644 index 000000000..52cb15ca6 --- /dev/null +++ b/.opencode/commands/worktree-merge.md @@ -0,0 +1,19 @@ +# 워크트리 작업 종료 후 상위 브랜치 머지 + +입력: `$ARGUMENTS` (예: `user`, `order`) + +아래 명령을 **실제로 실행**하세요. + +## 실행 명령 +```bash +git checkout shAn-kor +git merge --no-ff feature/$ARGUMENTS +git push origin shAn-kor +git worktree remove ../wt-$ARGUMENTS +git branch -d feature/$ARGUMENTS +``` + +## 출력 +- 실행한 명령 목록 +- 머지 커밋 해시 +- 정리 후 worktree 목록 (`git worktree list`) diff --git a/.opencode/oh-my-opencode.json b/.opencode/oh-my-opencode.json new file mode 100644 index 000000000..0aabd6e7b --- /dev/null +++ b/.opencode/oh-my-opencode.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", + "agents": { + "hephaestus": { + "model": "openai/gpt-5.3-codex", + "variant": "medium" + }, + "unspecified-low": { + "model": "openai/gpt-5.3-codex-spark", + "variant": "medium" + }, + "unspecified-high": { + "model": "openai/gpt-5.3-codex", + "variant": "medium" + } + }, + "categories": { + "deep": { + "model": "openai/gpt-5.3-codex", + "variant": "medium" + }, + "quick": { + "model": "openai/gpt-5.3-codex-spark", + "variant": "medium" + }, + "unspecified-low": { + "model": "openai/gpt-5.3-codex-spark", + "variant": "medium" + }, + "unspecified-high": { + "model": "openai/gpt-5.3-codex", + "variant": "medium" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..be2e2ac7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS Router + +이 파일은 상세 규칙을 직접 나열하지 않고, 작업 유형에 따라 참조할 규칙 파일을 지정한다. + +## Always (항상 적용) +- 개발 방향/의사결정은 제안까지만 하고, 최종 결정은 사용자 승인 후 반영한다. +- 요청 범위를 임의로 확장하지 않는다. +- 실제 동작하는 코드만 작성한다. 임시 목업/가짜 동작으로 완료 처리하지 않는다. +- 오버엔지니어링을 피한다. + +## Core Project Rules (항상 먼저 확인) +- `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/coding-style.md` +- `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/git-workflow.md` + +## Task-based Rules (작업 유형별 추가 적용) +- 테스트 작성/수정: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md` +- 성능 이슈/병목 개선: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/performance.md` +- 보안 관련 변경(인증/인가/입력 검증): `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/security.md` + +## Non-negotiable Architecture +- Facade는 여러 Application Service를 조합/오케스트레이션할 때만 사용한다 +- Facade -> Application Service only (Repository/Domain Service 직접 접근 금지) +- 단일 Application Service 호출만 필요한 유스케이스는 Facade를 만들지 않고 Controller -> Application Service로 직접 연결한다 +- Application Service -> 자기 도메인 Repository + Domain Service only +- Application Service 간 직접 호출 금지 (크로스 도메인 협력은 Facade에서 조정) +- Domain Service는 순수 비즈니스 규칙만 담당 (저장/외부 I/O/트랜잭션 금지) +- `@Transactional`은 Application Service에만 위치 (Facade/Domain Service 금지) +- 유니크 보장은 DB 제약으로 강제하고, 저장 시점 중복키 예외는 409로 변환 + +## Domain Constraints +- 결제 시스템 없음: 주문 완료 = 결제 완료 +- 주문 시점 상품 정보 스냅샷 저장 +- 어드민 인증은 `X-Loopers-Ldap` 헤더 기반 +- 포인트 충전 기능은 Scope-out + +## Project-specific Guardrails +- `password`/`token`/`secret` 필드는 `toString()`에서 마스킹 +- 비밀번호 정책 검증은 raw password에서만 수행 (encoded password 제외) +- 비밀번호 변경은 `@AuthMember` 단일 인증 사용 (body 재인증 금지) +- 예약어/문자열 케이스 변환은 `Locale.ROOT` 사용 diff --git a/CLAUDE.md b/CLAUDE.md index d40d12d20..df1674f2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,18 @@ - 성능 최적화 - 모든 테스트 케이스가 통과해야 함 +## 아키텍처 규칙 +- **Facade → Service만 호출**. Repository를 직접 접근하지 않는다. +- **Service → 자기 도메인 Repository만 접근**. 다른 도메인의 Service나 Repository를 호출하지 않는다. +- **Service 간 직접 호출 금지**. 크로스 도메인 협력은 반드시 Facade를 통해 이루어진다. +- **트랜잭션 위치**: `@Transactional`은 Domain Service 및 Application Service에만 위치한다. Facade에는 절대 `@Transactional`을 두지 않는다. 크로스 도메인 쓰기 시 각 Service가 자기 도메인 내에서 개별 트랜잭션을 관리하고, 실패 시 Facade에서 보상 로직을 처리한다. + +## 비즈니스 규칙 +- 결제 시스템 없음. 주문 완료 = 결제 완료로 취급한다. +- 주문 시점의 상품 정보를 스냅샷으로 저장한다 (주문 후 상품 변경에 영향받지 않도록). +- 어드민 인증은 X-Loopers-Ldap 헤더 기반으로 처리한다. +- 포인트 충전 기능은 범위 외 (Scope-out). + ## 주의사항 ### 1. Never Do - 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것 @@ -42,4 +54,4 @@ 2. null-safety, thread-safety 고려 3. 테스트 가능한 구조로 설계 4. 기존 코드 패턴 분석 후 일관성 유지 -5. 코드 뎁스는 1로 제한 \ No newline at end of file +5. 코드 뎁스는 1로 제한 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java new file mode 100644 index 000000000..ad09ac5e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -0,0 +1,62 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.application.brand.command.UpdateBrandCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandApplicationService { + + private final BrandRepository brandRepository; + + @Transactional + public Brand create(CreateBrandCommand command) { + BrandName brandName = new BrandName(command.name()); + + if (brandRepository.existsByName(brandName)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + } + + Brand brand = new Brand(brandName, command.description(), command.imageUrl()); + + try { + return brandRepository.save(brand); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + } + } + + @Transactional(readOnly = true) + public Brand findById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional + public Brand update(Long id, UpdateBrandCommand command) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + Brand updated = brand.update(command.description(), command.imageUrl()); + return brandRepository.save(updated); + } + + @Transactional + public void delete(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + brandRepository.deleteRelatedProducts(brand.id()); + + brandRepository.delete(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/command/CreateBrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/command/CreateBrandCommand.java new file mode 100644 index 000000000..b4259d51e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/command/CreateBrandCommand.java @@ -0,0 +1,16 @@ +package com.loopers.application.brand.command; + +import lombok.Builder; + +@Builder +public record CreateBrandCommand( + String name, + String description, + String imageUrl +) { + @Override + public String toString() { + return "CreateBrandCommand[name=%s, description=%s, imageUrl=%s]" + .formatted(name, description, imageUrl); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/command/UpdateBrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/command/UpdateBrandCommand.java new file mode 100644 index 000000000..18f7cb96f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/command/UpdateBrandCommand.java @@ -0,0 +1,15 @@ +package com.loopers.application.brand.command; + +import lombok.Builder; + +@Builder +public record UpdateBrandCommand( + String description, + String imageUrl +) { + @Override + public String toString() { + return "UpdateBrandCommand[description=%s, imageUrl=%s]" + .formatted(description, imageUrl); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java new file mode 100644 index 000000000..0efbbc692 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java @@ -0,0 +1,25 @@ +package com.loopers.application.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CategoryApplicationService { + private final CategoryRepository categoryRepository; + + public Category findById(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "카테고리를 찾을 수 없습니다.")); + } + + public Page list(Pageable pageable) { + return categoryRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..ae3e591c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,78 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.member.Member; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@Service +public class LikeApplicationService { + + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + + public LikeApplicationService(ProductRepository productRepository, LikeRepository likeRepository) { + this.productRepository = productRepository; + this.likeRepository = likeRepository; + } + + @Transactional + public void register(Long productId, Member member) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> findProductByIdIncludingDeletedOrThrow(productId)); + + if (likeRepository.existsByMemberIdAndProductId(member.id().value(), productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + } + + try { + likeRepository.save(new Like(member.id().value(), productId)); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + } + + productRepository.save(product.increaseLikeCount()); + } + + @Transactional + public void cancel(Long productId, Member member) { + String memberId = member.id().value(); + if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "좋아요한 상품이 아닙니다."); + } + + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + likeRepository.deleteByMemberIdAndProductId(memberId, productId); + productRepository.save(product.decreaseLikeCount()); + } + + public Page getMyLikes(String memberId, Pageable pageable) { + List products = likeRepository.findByMemberId(memberId, pageable) + .map(like -> productRepository.findById(like.productId()).orElse(null)) + .stream() + .filter(Objects::nonNull) + .toList(); + + return new PageImpl<>(products, pageable, products.size()); + } + + private CoreException findProductByIdIncludingDeletedOrThrow(Long productId) { + return productRepository.findByIdIncludingDeleted(productId) + .map(product -> new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품은 좋아요할 수 없습니다.")) + .orElse(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApplicationService.java new file mode 100644 index 000000000..759f44207 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApplicationService.java @@ -0,0 +1,87 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.ChangePasswordCommand; +import com.loopers.application.member.command.RegisterCommand; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MemberApplicationService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member register(RegisterCommand command) { + MemberId memberId = new MemberId(command.memberId()); + Password rawPassword = new Password(command.rawPassword()); + Name name = new Name(command.name()); + Email email = new Email(command.email()); + BirthDate birthDate = BirthDate.of(command.birthDate()); + Phone phone = new Phone(command.phone()); + + if (memberRepository.existsByMemberId(memberId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + + Member member = new Member(memberId, rawPassword, name, email, birthDate, phone); + Password encodedPassword = Password.ofEncoded(passwordEncoder.encode(member.password().value())); + Member memberWithEncodedPassword = new Member( + member.id(), + encodedPassword, + member.name(), + member.email(), + member.birthDate(), + member.phone() + ); + + try { + return memberRepository.save(memberWithEncodedPassword); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + } + + @Transactional(readOnly = true) + public boolean checkDuplicateLoginId(String loginId) { + MemberId memberId = new MemberId(loginId); + return memberRepository.existsByMemberId(memberId); + } + + @Transactional + public void changePassword(ChangePasswordCommand command) { + Member member = memberRepository.findByMemberId(command.memberId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + if (passwordEncoder.matches(command.newRawPassword(), member.password().value())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 다르게 설정해야 합니다."); + } + + Password newRawPassword = new Password(command.newRawPassword()); + new Member(member.id(), newRawPassword, member.name(), member.email(), member.birthDate(), member.phone()); + Password encodedPassword = Password.ofEncoded(passwordEncoder.encode(newRawPassword.value())); + Member updatedMember = new Member( + member.id(), + encodedPassword, + member.name(), + member.email(), + member.birthDate(), + member.phone() + ); + memberRepository.save(updatedMember); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java new file mode 100644 index 000000000..47b2c6673 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java @@ -0,0 +1,37 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.AuthenticateCommand; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class MemberAuthenticationService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public Member authenticate(AuthenticateCommand command) { + Member member = memberRepository.findByMemberId(command.memberId()) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(command.rawPassword(), member.password().value())) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return member; + } + + public Long findDbIdByMemberId(MemberId memberId) { + return memberRepository.findDbIdByMemberId(memberId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "회원을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/command/AuthenticateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/member/command/AuthenticateCommand.java new file mode 100644 index 000000000..ca761ae74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/command/AuthenticateCommand.java @@ -0,0 +1,36 @@ +package com.loopers.application.member.command; + +import com.loopers.domain.member.vo.MemberId; + +public record AuthenticateCommand( + MemberId memberId, + String rawPassword +) { + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private MemberId memberId; + private String rawPassword; + + public Builder memberId(MemberId memberId) { + this.memberId = memberId; + return this; + } + + public Builder rawPassword(String rawPassword) { + this.rawPassword = rawPassword; + return this; + } + + public AuthenticateCommand build() { + return new AuthenticateCommand(memberId, rawPassword); + } + } + + @Override + public String toString() { + return "AuthenticateCommand[memberId=%s, rawPassword=***]".formatted(memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/command/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/member/command/ChangePasswordCommand.java new file mode 100644 index 000000000..69012fc52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/command/ChangePasswordCommand.java @@ -0,0 +1,36 @@ +package com.loopers.application.member.command; + +import com.loopers.domain.member.vo.MemberId; + +public record ChangePasswordCommand( + MemberId memberId, + String newRawPassword +) { + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private MemberId memberId; + private String newRawPassword; + + public Builder memberId(MemberId memberId) { + this.memberId = memberId; + return this; + } + + public Builder newRawPassword(String newRawPassword) { + this.newRawPassword = newRawPassword; + return this; + } + + public ChangePasswordCommand build() { + return new ChangePasswordCommand(memberId, newRawPassword); + } + } + + @Override + public String toString() { + return "ChangePasswordCommand[memberId=%s, newRawPassword=***]".formatted(memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/command/RegisterCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/member/command/RegisterCommand.java new file mode 100644 index 000000000..906533480 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/command/RegisterCommand.java @@ -0,0 +1,63 @@ +package com.loopers.application.member.command; + +public record RegisterCommand( + String memberId, + String rawPassword, + String name, + String email, + String birthDate, + String phone +) { + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String memberId; + private String rawPassword; + private String name; + private String email; + private String birthDate; + private String phone; + + public Builder memberId(String memberId) { + this.memberId = memberId; + return this; + } + + public Builder rawPassword(String rawPassword) { + this.rawPassword = rawPassword; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder email(String email) { + this.email = email; + return this; + } + + public Builder birthDate(String birthDate) { + this.birthDate = birthDate; + return this; + } + + public Builder phone(String phone) { + this.phone = phone; + return this; + } + + public RegisterCommand build() { + return new RegisterCommand(memberId, rawPassword, name, email, birthDate, phone); + } + } + + @Override + public String toString() { + return "RegisterCommand[memberId=%s, rawPassword=***, name=%s, email=%s, birthDate=%s, phone=%s]" + .formatted(memberId, name, email, birthDate, phone); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java new file mode 100644 index 000000000..856c1c569 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,140 @@ +package com.loopers.application.order; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderApplicationService { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public Order create(CreateOrderCommand command) { + if (command.items() == null || command.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + + List productIds = command.items().stream() + .map(CreateOrderCommand.OrderItemCommand::productId) + .toList(); + + // 비관적 락으로 상품 조회 (재고 차감 동시성 보호) + List products = productRepository.findAllByIdInWithLock(productIds); + + // 존재하지 않는 상품 확인 + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + + Map productMap = products.stream() + .collect(Collectors.toMap(Product::id, p -> p)); + + // 삭제된 상품 확인 + for (Product p : products) { + if (p.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품이 포함되어 있습니다."); + } + } + + // 브랜드 조회 (스냅샷용) + Map brandMap = products.stream() + .map(Product::brandId) + .distinct() + .map(brandId -> brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."))) + .collect(Collectors.toMap(b -> b.id(), b -> b)); + + // 재고 차감 및 OrderItem 생성 + List orderItems = new ArrayList<>(); + for (CreateOrderCommand.OrderItemCommand itemCmd : command.items()) { + Product product = productMap.get(itemCmd.productId()); + Product updated = product.decreaseStock(itemCmd.quantity()); + productRepository.save(updated); + + Brand brand = brandMap.get(product.brandId()); + orderItems.add(new OrderItem( + product.id(), + itemCmd.quantity(), + product.name(), + product.price(), + brand.name().value() + )); + } + + String orderNumber = UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(); + Order order = new Order(command.userId(), orderNumber, orderItems); + return orderRepository.save(order); + } + + @Transactional + public Order cancel(Long orderId, Long userId, boolean isAdmin) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!isAdmin && !order.isOwner(userId)) { + throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 취소할 수 없습니다."); + } + + Order cancelled = order.cancel(); // 이미 취소된 경우 409 CONFLICT 던짐 + + // 재고 복원 + for (OrderItem item : order.items()) { + productRepository.findById(item.productId()).ifPresent(product -> { + Product restored = product.increaseStock(item.quantity()); + productRepository.save(restored); + }); + } + + return orderRepository.save(cancelled); + } + + @Transactional(readOnly = true) + public Order getById(Long orderId, Long userId, boolean isAdmin) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!isAdmin && !order.isOwner(userId)) { + throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 조회할 수 없습니다."); + } + + return order; + } + + @Transactional(readOnly = true) + public Page listByUser(Long userId, LocalDate startAt, LocalDate endAt, Pageable pageable) { + ZoneId kst = ZoneId.of("Asia/Seoul"); + ZonedDateTime startDateTime = startAt.atStartOfDay(kst); + ZonedDateTime endDateTime = endAt.atTime(23, 59, 59).atZone(kst); + return orderRepository.findByUserId(userId, startDateTime, endDateTime, pageable); + } + + @Transactional(readOnly = true) + public Page listAll(Pageable pageable) { + return orderRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java new file mode 100644 index 000000000..04ff7acfa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.order.command; + +import java.util.List; + +public record CreateOrderCommand( + Long userId, + List items +) { + public record OrderItemCommand(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java new file mode 100644 index 000000000..5c257dfc2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -0,0 +1,54 @@ +package com.loopers.application.product; + +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductApplicationService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + + @Transactional + public Product create(CreateProductCommand command) { + if (brandRepository.findById(command.brandId()).isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 브랜드입니다."); + } + if (categoryRepository.findById(command.categoryId()).isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 카테고리입니다."); + } + + Product product = new Product( + command.name(), + command.price(), + command.stock(), + command.description(), + command.categoryId(), + command.brandId() + ); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public Product get(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page list(Long brandId, Pageable pageable) { + return productRepository.findAll(brandId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java new file mode 100644 index 000000000..9f51a8560 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java @@ -0,0 +1,11 @@ +package com.loopers.application.product.command; + +public record CreateProductCommand( + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId +) { +} 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 deleted file mode 100644 index fba67a8a5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserAuthService; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class UserFacade { - - private final UserAuthService userAuthService; - private final UserService userService; - - public void changePassword(UserFacadeDto.ChangePasswordRequest request) { - User user = userAuthService.authenticate(request.toAuthenticateCommand()); - userService.changePassword(request.toChangePasswordCommand(user.birthDate())); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacadeDto.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacadeDto.java deleted file mode 100644 index 49961af7b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacadeDto.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.application.user.command.AuthenticateCommand; -import com.loopers.application.user.command.ChangePasswordCommand; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.UserId; -import lombok.Builder; - -public class UserFacadeDto { - - @Builder - public record ChangePasswordRequest( - UserId userId, - String currentPassword, - String newPassword - ) { - public AuthenticateCommand toAuthenticateCommand() { - return AuthenticateCommand.builder() - .userId(userId) - .rawPassword(currentPassword) - .build(); - } - - public ChangePasswordCommand toChangePasswordCommand(BirthDate birthDate) { - return ChangePasswordCommand.builder() - .userId(userId) - .newRawPassword(newPassword) - .birthDate(birthDate) - .build(); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/command/AuthenticateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/command/AuthenticateCommand.java deleted file mode 100644 index bcc34c6ac..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/command/AuthenticateCommand.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.application.user.command; - -import com.loopers.domain.user.vo.UserId; -import lombok.Builder; - -@Builder -public record AuthenticateCommand( - UserId userId, - String rawPassword -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/command/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/command/ChangePasswordCommand.java deleted file mode 100644 index 1262b461c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/command/ChangePasswordCommand.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.user.command; - -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.UserId; -import lombok.Builder; - -@Builder -public record ChangePasswordCommand( - UserId userId, - String newRawPassword, - BirthDate birthDate -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/command/RegisterCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/command/RegisterCommand.java deleted file mode 100644 index 0dba08c59..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/command/RegisterCommand.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.user.command; - -import lombok.Builder; - -@Builder -public record RegisterCommand( - String userId, - String rawPassword, - String name, - String email, - String birthDate -) { -} 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..2e8831ef1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,31 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandName; + +public record Brand( + Long id, + BrandName name, + String description, + String imageUrl +) { + + public Brand(BrandName name, String description, String imageUrl) { + this(null, name, description, imageUrl); + } + + public Brand updateDescription(String description) { + return new Brand(this.id, this.name, description, this.imageUrl); + } + + public Brand updateImageUrl(String imageUrl) { + return new Brand(this.id, this.name, this.description, imageUrl); + } + + public Brand update(String description, String imageUrl) { + return new Brand(this.id, this.name, description, imageUrl); + } + + public boolean canDelete() { + return true; + } +} 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..8a1a98c5c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandName; + +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + + Optional findById(Long id); + + boolean existsById(Long id); + + boolean existsByName(BrandName name); + + void deleteRelatedProducts(Long brandId); + + void delete(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/exception/BrandValidationException.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/exception/BrandValidationException.java new file mode 100644 index 000000000..de7fdab18 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/exception/BrandValidationException.java @@ -0,0 +1,8 @@ +package com.loopers.domain.brand.exception; + +public class BrandValidationException extends RuntimeException { + + public BrandValidationException(String message) { + super(message); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java new file mode 100644 index 000000000..5f38dbe09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java @@ -0,0 +1,27 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.domain.brand.exception.BrandValidationException; + +import java.util.regex.Pattern; + +public record BrandName(String value) { + + private static final int MAX_LENGTH = 50; + private static final Pattern VALID_PATTERN = Pattern.compile("^[a-zA-Z0-9가-힣\\-_\\.]+$"); + + public BrandName { + validate(value); + } + + private void validate(String name) { + if (name == null || name.isBlank()) { + throw new BrandValidationException("브랜드 이름은 필수입니다."); + } + if (name.length() > MAX_LENGTH) { + throw new BrandValidationException("브랜드 이름은 " + MAX_LENGTH + "자를 초과할 수 없습니다."); + } + if (!VALID_PATTERN.matcher(name).matches()) { + throw new BrandValidationException("브랜드 이름에는 영문, 한글, 숫자, '-', '_', '.'만 사용할 수 있습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java new file mode 100644 index 000000000..5f930fc17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java @@ -0,0 +1,19 @@ +package com.loopers.domain.category; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Category( + Long id, + String name +) { + public Category(String name) { + this(null, name); + } + + public Category { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카테고리 이름은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java new file mode 100644 index 000000000..61df675d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.category; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface CategoryRepository { + Category save(Category category); + + Optional findById(Long id); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..cd2e10cdf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,4 @@ +package com.loopers.domain.like; + +public record Like(String memberId, Long productId) { +} 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..081e72fd0 --- /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 org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface LikeRepository { + Like save(Like like); + + boolean existsByMemberIdAndProductId(String memberId, Long productId); + + void deleteByMemberIdAndProductId(String memberId, Long productId); + + Page findByMemberId(String memberId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..8a78a4dc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,58 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.exception.MemberValidationException; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; + +public record Member( + MemberId id, + Password password, + Name name, + Email email, + BirthDate birthDate, + Phone phone +) { + private static final String ERROR_PASSWORD_CONTAINS_BIRTHDATE = "비밀번호에 생년월일을 포함할 수 없습니다"; + + public Member(MemberId id, Password password, Name name, Email email, Phone phone) { + this(id, password, name, email, null, phone); + } + + public Member { + if (birthDate != null && phone != null) { + validatePasswordNotContainsBirthDate(password, birthDate); + } + } + + private void validatePasswordNotContainsBirthDate(Password password, BirthDate birthDate) { + if (password == null || birthDate == null) { + return; + } + if (password.isEncoded()) { + return; + } + if (password.containsDate(birthDate.value())) { + throw new MemberValidationException(ERROR_PASSWORD_CONTAINS_BIRTHDATE); + } + } + + public String getMaskedName() { + String nameValue = name.value(); + if (nameValue.length() <= 1) { + return "*"; + } + return nameValue.substring(0, nameValue.length() - 1) + "*"; + } + + public Phone phone() { + return phone; + } + + public BirthDate birthDate() { + return birthDate; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..91c413427 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.MemberId; + +import java.util.Optional; + +public interface MemberRepository { + + Optional findByMemberId(MemberId memberId); + + Optional findDbIdByMemberId(MemberId memberId); + + boolean existsByMemberId(MemberId memberId); + + Member save(Member member); +} 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/member/PasswordEncoder.java similarity index 80% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java index 3393d4fcd..056131f08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.member; public interface PasswordEncoder { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/exception/MemberValidationException.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/exception/MemberValidationException.java new file mode 100644 index 000000000..c7729cf72 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/exception/MemberValidationException.java @@ -0,0 +1,8 @@ +package com.loopers.domain.member.exception; + +public class MemberValidationException extends RuntimeException { + + public MemberValidationException(String message) { + super(message); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java similarity index 78% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java index 40c179729..a4aabd9ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -18,22 +18,22 @@ public record BirthDate(LocalDate value) { public BirthDate { if (value == null) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); } if (value.isAfter(LocalDate.now())) { - throw new UserValidationException(ERROR_FUTURE_DATE); + throw new MemberValidationException(ERROR_FUTURE_DATE); } } public static BirthDate of(String dateString) { if (dateString == null || dateString.isBlank()) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); } try { LocalDate date = LocalDate.parse(dateString, FORMATTER); return new BirthDate(date); } catch (DateTimeParseException e) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new MemberValidationException(ERROR_INVALID_FORMAT); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java similarity index 75% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java index b0062cc88..c4113a1b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import java.util.regex.Pattern; @@ -30,36 +30,36 @@ public record Email(String value) { private void validate(String email) { if (email == null || email.isBlank()) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); } if (email.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + throw new MemberValidationException(ERROR_MAX_LENGTH); } int atIndex = email.indexOf('@'); if (atIndex == -1) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new MemberValidationException(ERROR_INVALID_FORMAT); } String localPart = email.substring(0, atIndex); if (localPart.length() > LOCAL_PART_MAX_LENGTH) { - throw new UserValidationException(ERROR_LOCAL_PART_MAX_LENGTH); + throw new MemberValidationException(ERROR_LOCAL_PART_MAX_LENGTH); } if (localPart.startsWith(".")) { - throw new UserValidationException(ERROR_STARTS_WITH_DOT); + throw new MemberValidationException(ERROR_STARTS_WITH_DOT); } if (localPart.endsWith(".")) { - throw new UserValidationException(ERROR_ENDS_WITH_DOT); + throw new MemberValidationException(ERROR_ENDS_WITH_DOT); } if (CONSECUTIVE_DOTS_PATTERN.matcher(email).find()) { - throw new UserValidationException(ERROR_CONSECUTIVE_DOTS); + throw new MemberValidationException(ERROR_CONSECUTIVE_DOTS); } if (INVALID_LOCAL_CHARS_PATTERN.matcher(localPart).find()) { - throw new UserValidationException(ERROR_INVALID_LOCAL_CHARS); + throw new MemberValidationException(ERROR_INVALID_LOCAL_CHARS); } if (!EMAIL_PATTERN.matcher(email).matches()) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new MemberValidationException(ERROR_INVALID_FORMAT); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java similarity index 57% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java index e29afc197..9f0674cf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -1,11 +1,12 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; +import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; -public record UserId(String value) { +public record MemberId(String value) { private static final int MIN_LENGTH = 4; private static final int MAX_LENGTH = 20; @@ -24,25 +25,25 @@ public record UserId(String value) { private static final String ERROR_INVALID_FORMAT = "아이디는 영문자로 시작하고, 영문/숫자만 사용할 수 있습니다"; private static final String ERROR_RESERVED_WORD = "사용할 수 없는 아이디입니다"; - public UserId { + public MemberId { validate(value); } - private void validate(String userId) { - if (userId == null || userId.isBlank()) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + private void validate(String memberId) { + if (memberId == null || memberId.isBlank()) { + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); } - if (userId.length() < MIN_LENGTH) { - throw new UserValidationException(ERROR_MIN_LENGTH); + if (memberId.length() < MIN_LENGTH) { + throw new MemberValidationException(ERROR_MIN_LENGTH); } - if (userId.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + if (memberId.length() > MAX_LENGTH) { + throw new MemberValidationException(ERROR_MAX_LENGTH); } - if (!ALLOWED_CHARS_PATTERN.matcher(userId).matches()) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + if (!ALLOWED_CHARS_PATTERN.matcher(memberId).matches()) { + throw new MemberValidationException(ERROR_INVALID_FORMAT); } - if (RESERVED_WORDS.contains(userId.toLowerCase())) { - throw new UserValidationException(ERROR_RESERVED_WORD); + if (RESERVED_WORDS.contains(memberId.toLowerCase(Locale.ROOT))) { + throw new MemberValidationException(ERROR_RESERVED_WORD); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java similarity index 80% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Name.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java index 7c32c6518..72ff2177f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import java.util.regex.Pattern; @@ -28,24 +28,24 @@ private void validate(String name) { private void validateNotEmpty(String name) { if (name == null || name.isBlank()) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); } } private void validateFormat(String name) { if (isKoreanOnly(name) && name.length() > KOREAN_MAX_LENGTH) { - throw new UserValidationException(ERROR_KOREAN_MAX_LENGTH); + throw new MemberValidationException(ERROR_KOREAN_MAX_LENGTH); } if (isKoreanOnly(name)) { return; } if (isEnglishOnly(name) && name.length() > ENGLISH_MAX_LENGTH) { - throw new UserValidationException(ERROR_ENGLISH_MAX_LENGTH); + throw new MemberValidationException(ERROR_ENGLISH_MAX_LENGTH); } if (isEnglishOnly(name)) { return; } - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new MemberValidationException(ERROR_INVALID_FORMAT); } private boolean isKoreanOnly(String name) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java similarity index 74% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java index 1f81f754a..e77da9909 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -25,6 +25,7 @@ private enum EncodedMarker { INSTANCE } private static final String ERROR_LOWERCASE_REQUIRED = "소문자를 포함해야 합니다"; private static final String ERROR_DIGIT_REQUIRED = "숫자를 포함해야 합니다"; private static final String ERROR_SPECIAL_CHAR_REQUIRED = "특수문자를 포함해야 합니다"; + private static final String ERROR_NOT_ENCODED = "비밀번호가 암호화되지 않았습니다"; private final String value; @@ -38,6 +39,9 @@ private Password(String value, EncodedMarker marker) { } public static Password ofEncoded(String encodedPassword) { + if (!isEncodedFormat(encodedPassword)) { + throw new MemberValidationException(ERROR_NOT_ENCODED); + } return new Password(encodedPassword, EncodedMarker.INSTANCE); } @@ -47,22 +51,22 @@ public String value() { private void validate(String password) { if (password == null || password.length() < MIN_LENGTH) { - throw new UserValidationException(ERROR_MIN_LENGTH); + throw new MemberValidationException(ERROR_MIN_LENGTH); } if (password.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + throw new MemberValidationException(ERROR_MAX_LENGTH); } if (!UPPERCASE_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_UPPERCASE_REQUIRED); + throw new MemberValidationException(ERROR_UPPERCASE_REQUIRED); } if (!LOWERCASE_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_LOWERCASE_REQUIRED); + throw new MemberValidationException(ERROR_LOWERCASE_REQUIRED); } if (!DIGIT_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_DIGIT_REQUIRED); + throw new MemberValidationException(ERROR_DIGIT_REQUIRED); } if (!SPECIAL_CHAR_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_SPECIAL_CHAR_REQUIRED); + throw new MemberValidationException(ERROR_SPECIAL_CHAR_REQUIRED); } } @@ -77,7 +81,11 @@ public boolean containsDate(LocalDate date) { } public boolean isEncoded() { - return value != null && (value.startsWith("$2a$") || value.startsWith("$2b$")); + return isEncodedFormat(value); + } + + private static boolean isEncodedFormat(String password) { + return password != null && (password.startsWith("$2a$") || password.startsWith("$2b$") || password.startsWith("$2y$")); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Phone.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Phone.java new file mode 100644 index 000000000..9a59032ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Phone.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.exception.MemberValidationException; + +import java.util.Locale; +import java.util.regex.Pattern; + +public record Phone(String value) { + + private static final Pattern PHONE_PATTERN = Pattern.compile("^010-\\d{4}-\\d{4}$"); + + private static final String ERROR_NULL_OR_EMPTY = "전화번호는 필수 입력값입니다"; + private static final String ERROR_INVALID_FORMAT = "전화번호는 010-XXXX-XXXX 형식이어야 합니다"; + + public Phone { + validate(value); + } + + private void validate(String phone) { + if (phone == null || phone.isBlank()) { + throw new MemberValidationException(ERROR_NULL_OR_EMPTY); + } + if (!PHONE_PATTERN.matcher(phone).matches()) { + throw new MemberValidationException(ERROR_INVALID_FORMAT); + } + } +} 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..71d534dac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,56 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; +import java.util.List; + +public record Order( + Long id, + Long userId, + String orderNumber, + ZonedDateTime orderDate, + OrderStatus status, + int totalAmount, + List items, + ZonedDateTime deletedAt +) { + + public Order { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + } + + public Order(Long userId, String orderNumber, List items) { + this( + null, + userId, + orderNumber, + ZonedDateTime.now(), + OrderStatus.ORDERED, + items.stream().mapToInt(OrderItem::totalPrice).sum(), + items, + null + ); + } + + public Order cancel() { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.CONFLICT, "이미 취소된 주문입니다."); + } + return new Order(id, userId, orderNumber, orderDate, OrderStatus.CANCELLED, totalAmount, items, deletedAt); + } + + public boolean isOwner(Long userId) { + return this.userId.equals(userId); + } + + public boolean isCancelled() { + return this.status == OrderStatus.CANCELLED; + } +} 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..93b3034d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,38 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderItem( + Long id, + Long orderId, + Long productId, + int quantity, + String snapshotProductName, + int snapshotPrice, + String snapshotBrandName +) { + + public OrderItem { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + if (snapshotProductName == null || snapshotProductName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명 스냅샷은 필수입니다."); + } + if (snapshotPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격 스냅샷은 0 이상이어야 합니다."); + } + if (snapshotBrandName == null || snapshotBrandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명 스냅샷은 필수입니다."); + } + } + + public OrderItem(Long productId, int quantity, String snapshotProductName, int snapshotPrice, String snapshotBrandName) { + this(null, null, productId, quantity, snapshotProductName, snapshotPrice, snapshotBrandName); + } + + public int totalPrice() { + return snapshotPrice * quantity; + } +} 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..7d3116864 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + + Optional findById(Long id); + + Page findByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable); + + Page findAll(Pageable pageable); + boolean existsOrderItemByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..b2d11834f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ORDERED, + CANCELLED +} 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..9c9c3c62d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,75 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.ZonedDateTime; + +public record Product( + Long id, + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId, + Integer likeCount, + ZonedDateTime deletedAt +) { + + public Product { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + if (categoryId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "카테고리 ID는 필수입니다."); + } + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 이상이어야 합니다."); + } + } + + public Product(String name, Integer price, Integer stock, String description, Long categoryId, Long brandId) { + this(null, name, price, stock, description, categoryId, brandId, 0, null); + } + + public Product increaseLikeCount() { + return new Product(id, name, price, stock, description, categoryId, brandId, likeCount + 1, deletedAt); + } + + public Product decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + int nextStock = stock - quantity; + if (nextStock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Product(id, name, price, nextStock, description, categoryId, brandId, likeCount, deletedAt); + } + + public Product increaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + return new Product(id, name, price, stock + quantity, description, categoryId, brandId, likeCount, deletedAt); + } + + public Product decreaseLikeCount() { + int nextLikeCount = Math.max(likeCount - 1, 0); + return new Product(id, name, price, stock, description, categoryId, brandId, nextLikeCount, deletedAt); + } + + public boolean isDeleted() { + return deletedAt != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..5de0da04a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + + Optional findById(Long id); + + List findAllByIdInWithLock(List ids); + + Optional findByIdIncludingDeleted(Long id); + + Page findAll(Long brandId, Pageable pageable); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java deleted file mode 100644 index 3e00ca528..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.user.exception.UserValidationException; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; - -public record User( - UserId id, - Password password, - Name name, - Email email, - BirthDate birthDate -) { - private static final String ERROR_PASSWORD_CONTAINS_BIRTHDATE = "비밀번호에 생년월일을 포함할 수 없습니다"; - - public User { - validatePasswordNotContainsBirthDate(password, birthDate); - } - - private void validatePasswordNotContainsBirthDate(Password password, BirthDate birthDate) { - if (password == null || birthDate == null) { - return; - } - if (password.containsDate(birthDate.value())) { - throw new UserValidationException(ERROR_PASSWORD_CONTAINS_BIRTHDATE); - } - } - - public String getMaskedName() { - String nameValue = name.value(); - if (nameValue.length() <= 1) { - return "*"; - } - return nameValue.substring(0, nameValue.length() - 1) + "*"; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserAuthService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserAuthService.java deleted file mode 100644 index 972b31195..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserAuthService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.command.AuthenticateCommand; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -public class UserAuthService { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional(readOnly = true) - public User authenticate(AuthenticateCommand command) { - User user = userRepository.findByUserId(command.userId()) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); - - if (!passwordEncoder.matches(command.rawPassword(), user.password().value())) { - throw new CoreException(ErrorType.UNAUTHORIZED); - } - - return user; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java deleted file mode 100644 index e1992694d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.user.vo.UserId; - -import java.util.Optional; - -public interface UserRepository { - - Optional findByUserId(UserId userId); - - boolean existsByUserId(UserId userId); - - User save(User user); -} 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 deleted file mode 100644 index e1f7c233b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.command.ChangePasswordCommand; -import com.loopers.application.user.command.RegisterCommand; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -public class UserService { - - private static final String ERROR_PASSWORD_NOT_ENCODED = "비밀번호가 암호화되지 않았습니다"; - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional - public User register(RegisterCommand command) { - UserId userId = new UserId(command.userId()); - Password password = new Password(command.rawPassword()); - Name name = new Name(command.name()); - Email email = new Email(command.email()); - BirthDate birthDate = BirthDate.of(command.birthDate()); - - if (userRepository.existsByUserId(userId)) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); - } - - User user = new User(userId, password, name, email, birthDate); - Password encodedPassword = Password.ofEncoded(passwordEncoder.encode(user.password().value())); - if (!encodedPassword.isEncoded()) { - throw new CoreException(ErrorType.INTERNAL_ERROR, ERROR_PASSWORD_NOT_ENCODED); - } - User userWithEncodedPassword = new User(user.id(), encodedPassword, user.name(), user.email(), user.birthDate()); - return userRepository.save(userWithEncodedPassword); - } - - @Transactional - public void changePassword(ChangePasswordCommand command) { - User user = userRepository.findByUserId(command.userId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - - if (passwordEncoder.matches(command.newRawPassword(), user.password().value())) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 다르게 설정해야 합니다."); - } - - Password newPassword = new Password(command.newRawPassword()); - Password encodedPassword = Password.ofEncoded(passwordEncoder.encode(newPassword.value())); - if (!encodedPassword.isEncoded()) { - throw new CoreException(ErrorType.INTERNAL_ERROR, ERROR_PASSWORD_NOT_ENCODED); - } - User updatedUser = new User(user.id(), encodedPassword, user.name(), user.email(), command.birthDate()); - userRepository.save(updatedUser); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/exception/UserValidationException.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/exception/UserValidationException.java deleted file mode 100644 index 5d593417f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/exception/UserValidationException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.domain.user.exception; - -public class UserValidationException extends RuntimeException { - - public UserValidationException(String message) { - super(message); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..ebd7b61ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.vo.BrandName; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "brands") +public class BrandEntity extends BaseEntity { + + @Getter + @Column(name = "name", nullable = false, unique = true) + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "image_url") + private String imageUrl; + + protected BrandEntity() {} + + public BrandEntity(String name, String description, String imageUrl) { + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + } + + public static BrandEntity from(Brand brand) { + return new BrandEntity( + brand.name().value(), + brand.description(), + brand.imageUrl() + ); + } + + public Brand toDomain() { + return new Brand( + getId(), + new BrandName(name), + description, + imageUrl + ); + } + + public void updateFrom(Brand brand) { + this.description = brand.description(); + this.imageUrl = brand.imageUrl(); + } +} 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..c1c9ac036 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.brand; + +import org.springframework.data.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.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByName(String name); + + boolean existsByName(String name); + + @Modifying + @Query(value = "UPDATE products p SET p.deleted_at = CURRENT_TIMESTAMP WHERE p.brand_id = :brandId AND p.deleted_at IS NULL", nativeQuery = true) + int softDeleteProductsByBrandId(@Param("brandId") Long brandId); +} 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..89682d0ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + BrandEntity entity = BrandEntity.from(brand); + BrandEntity saved = brandJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .filter(entity -> entity.getDeletedAt() == null) + .map(BrandEntity::toDomain); + } + + @Override + public boolean existsById(Long id) { + return brandJpaRepository.existsById(id); + } + + @Override + public boolean existsByName(BrandName name) { + return brandJpaRepository.existsByName(name.value()); + } + + @Override + public void deleteRelatedProducts(Long brandId) { + brandJpaRepository.softDeleteProductsByBrandId(brandId); + } + + @Override + public void delete(Brand brand) { + brandJpaRepository.findById(brand.id()) + .ifPresent(BrandEntity::delete); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java new file mode 100644 index 000000000..56d5bb03d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.category; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.category.Category; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "categories") +public class CategoryEntity extends BaseEntity { + @Column(nullable = false, unique = true) + private String name; + + protected CategoryEntity() {} + + public CategoryEntity(String name) { + this.name = name; + } + + public static CategoryEntity from(Category category) { + return new CategoryEntity(category.name()); + } + + public Category toDomain() { + return new Category(getId(), name); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java new file mode 100644 index 000000000..f31d676a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.category; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CategoryJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + boolean existsByIdAndDeletedAtIsNull(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java new file mode 100644 index 000000000..a0d1aa7ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CategoryRepositoryImpl implements CategoryRepository { + private final CategoryJpaRepository categoryJpaRepository; + + @Override + public Category save(Category category) { + CategoryEntity saved = categoryJpaRepository.save(CategoryEntity.from(category)); + return saved.toDomain(); + } + + @Override + public Optional findById(Long id) { + return categoryJpaRepository.findByIdAndDeletedAtIsNull(id) + .map(CategoryEntity::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return categoryJpaRepository.findAllByDeletedAtIsNull(pageable) + .map(CategoryEntity::toDomain); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java new file mode 100644 index 000000000..38cbe1ac0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.like.Like; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "likes", uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "product_id"})) +public class LikeEntity extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private String memberId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected LikeEntity() { + } + + public LikeEntity(String memberId, Long productId) { + this.memberId = memberId; + this.productId = productId; + } + + public static LikeEntity from(Like like) { + return new LikeEntity(like.memberId(), like.productId()); + } + + public Like toDomain() { + return new Like(memberId, productId); + } + + public String getMemberId() { + return memberId; + } + + public Long getProductId() { + return 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 new file mode 100644 index 000000000..7f2aa779b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndProductId(String memberId, Long productId); + + void deleteByMemberIdAndProductId(String memberId, Long productId); + + Page findByMemberIdOrderByCreatedAtDesc(String memberId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..8ab9d5326 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + LikeEntity entity = LikeEntity.from(like); + return likeJpaRepository.save(entity).toDomain(); + } + + @Override + public boolean existsByMemberIdAndProductId(String memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public void deleteByMemberIdAndProductId(String memberId, Long productId) { + likeJpaRepository.deleteByMemberIdAndProductId(memberId, productId); + } + + @Override + public Page findByMemberId(String memberId, Pageable pageable) { + return likeJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable) + .map(LikeEntity::toDomain); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/BCryptPasswordEncoderImpl.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/member/BCryptPasswordEncoderImpl.java index ea74340aa..7f4c96a6d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/BCryptPasswordEncoderImpl.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.user; +package com.loopers.infrastructure.member; -import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.member.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java new file mode 100644 index 000000000..366cb5ac7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table(name = "members") +public class MemberEntity extends BaseEntity { + + @Getter + @Column(name = "member_id", nullable = false, unique = true) + private String memberId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "phone") + private String phone; + + protected MemberEntity() { + } + + public MemberEntity(String memberId, String password, String name, String email, LocalDate birthDate, String phone) { + this.memberId = memberId; + this.password = password; + this.name = name; + this.email = email; + this.birthDate = birthDate; + this.phone = phone; + } + + public static MemberEntity from(Member member) { + return new MemberEntity( + member.id().value(), + member.password().value(), + member.name().value(), + member.email().value(), + member.birthDate().value(), + member.phone() != null ? member.phone().value() : null + ); + } + + public Member toDomain() { + return new Member( + new MemberId(memberId), + Password.ofEncoded(password), + new Name(name), + new Email(email), + new BirthDate(birthDate), + phone != null ? new Phone(phone) : null + ); + } + + public void updatePassword(String encodedPassword) { + this.password = encodedPassword; + } + + public void updateFrom(Member member) { + this.password = member.password().value(); + this.name = member.name().value(); + this.email = member.email().value(); + this.birthDate = member.birthDate().value(); + this.phone = member.phone() != null ? member.phone().value() : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..ba9701aad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByMemberId(String memberId); + + boolean existsByMemberId(String memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..836110438 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Optional findByMemberId(MemberId memberId) { + return memberJpaRepository.findByMemberId(memberId.value()) + .map(com.loopers.infrastructure.member.MemberEntity::toDomain); + } + + @Override + public Optional findDbIdByMemberId(MemberId memberId) { + return memberJpaRepository.findByMemberId(memberId.value()) + .map(com.loopers.infrastructure.member.MemberEntity::getId); + } + + @Override + public boolean existsByMemberId(MemberId memberId) { + return memberJpaRepository.existsByMemberId(memberId.value()); + } + + @Override + public Member save(Member member) { + if (!member.password().isEncoded()) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "비밀번호가 암호화되지 않았습니다"); + } + + Optional existingModel = memberJpaRepository.findByMemberId(member.id().value()); + + if (existingModel.isPresent()) { + MemberEntity model = existingModel.get(); + model.updateFrom(member); + return memberJpaRepository.save(model).toDomain(); + } + + MemberEntity newModel = com.loopers.infrastructure.member.MemberEntity.from(member); + return memberJpaRepository.save(newModel).toDomain(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java new file mode 100644 index 000000000..d84d15502 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +public class OrderEntity extends BaseEntity { + + @Column(name = "user_id", nullable = false) + @Getter + private Long userId; + + @Column(name = "order_number", nullable = false, unique = true) + @Getter + private String orderNumber; + + @Column(name = "order_date", nullable = false) + @Getter + private ZonedDateTime orderDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Getter + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + @Getter + private int totalAmount; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + protected OrderEntity() {} + + public OrderEntity(Long userId, String orderNumber, ZonedDateTime orderDate, OrderStatus status, int totalAmount) { + this.userId = userId; + this.orderNumber = orderNumber; + this.orderDate = orderDate; + this.status = status; + this.totalAmount = totalAmount; + } + + public static OrderEntity from(Order order) { + return new OrderEntity( + order.userId(), + order.orderNumber(), + order.orderDate(), + order.status(), + order.totalAmount() + ); + } + + public void addItem(OrderItemEntity item) { + this.items.add(item); + } + + public void cancel() { + this.status = OrderStatus.CANCELLED; + } + + public Order toDomain() { + return new Order( + getId(), + userId, + orderNumber, + orderDate, + status, + totalAmount, + items.stream().map(OrderItemEntity::toDomain).toList(), + getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java new file mode 100644 index 000000000..c8347338a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "order_items") +public class OrderItemEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Getter + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderEntity order; + + @Column(name = "product_id") + @Getter + private Long productId; + + @Column(nullable = false) + @Getter + private int quantity; + + @Column(name = "snapshot_product_name", nullable = false) + @Getter + private String snapshotProductName; + + @Column(name = "snapshot_price", nullable = false) + @Getter + private int snapshotPrice; + + @Column(name = "snapshot_brand_name", nullable = false) + @Getter + private String snapshotBrandName; + + protected OrderItemEntity() {} + + public OrderItemEntity(OrderEntity order, OrderItem item) { + this.order = order; + this.productId = item.productId(); + this.quantity = item.quantity(); + this.snapshotProductName = item.snapshotProductName(); + this.snapshotPrice = item.snapshotPrice(); + this.snapshotBrandName = item.snapshotBrandName(); + } + + public OrderItem toDomain() { + return new OrderItem( + id, + order.getId(), + productId, + quantity, + snapshotProductName, + snapshotPrice, + snapshotBrandName + ); + } +} 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..dba8dad3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; + +public interface OrderJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OrderEntity o WHERE o.userId = :userId " + + "AND o.orderDate >= :startAt AND o.orderDate <= :endAt " + + "AND o.deletedAt IS NULL") + Page findByUserIdAndOrderDateBetween( + @Param("userId") Long userId, + @Param("startAt") ZonedDateTime startAt, + @Param("endAt") ZonedDateTime endAt, + Pageable pageable + ); + + @Query("SELECT o FROM OrderEntity o WHERE o.deletedAt IS NULL") + Page findAllActive(Pageable pageable); + + @Query("SELECT COUNT(i) > 0 FROM OrderItemEntity i WHERE i.productId = :productId") + boolean existsByProductId(@Param("productId") Long productId); +} 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..9bdb584c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + OrderEntity entity; + if (order.id() != null) { + entity = orderJpaRepository.findById(order.id()) + .orElseGet(() -> OrderEntity.from(order)); + entity.cancel(); + } else { + entity = OrderEntity.from(order); + for (OrderItem item : order.items()) { + OrderItemEntity itemEntity = new OrderItemEntity(entity, item); + entity.addItem(itemEntity); + } + } + return orderJpaRepository.save(entity).toDomain(); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id) + .filter(e -> e.getDeletedAt() == null) + .map(OrderEntity::toDomain); + } + + @Override + public Page findByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable) { + return orderJpaRepository.findByUserIdAndOrderDateBetween(userId, startAt, endAt, pageable) + .map(OrderEntity::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAllActive(pageable) + .map(OrderEntity::toDomain); + } + + @Override + public boolean existsOrderItemByProductId(Long productId) { + return orderJpaRepository.existsByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java new file mode 100644 index 000000000..f0dda2cd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,88 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "products") +public class ProductEntity extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer price; + + @Column(nullable = false) + private Integer stock; + + @Column(name = "description") + private String description; + + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + protected ProductEntity() { + } + + public ProductEntity( + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId, + Integer likeCount + ) { + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.categoryId = categoryId; + this.brandId = brandId; + this.likeCount = likeCount; + } + + public static ProductEntity from(Product product) { + return new ProductEntity( + product.name(), + product.price(), + product.stock(), + product.description(), + product.categoryId(), + product.brandId(), + product.likeCount() + ); + } + + public Product toDomain() { + return new Product( + getId(), + name, + price, + stock, + description, + categoryId, + brandId, + likeCount, + getDeletedAt() + ); + } + + public void updateFrom(Product product) { + this.name = product.name(); + this.price = product.price(); + this.stock = product.stock(); + this.description = product.description(); + this.likeCount = product.likeCount(); + } +} 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..cf26b1d58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.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.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductEntity p WHERE p.id IN :ids AND p.deletedAt IS NULL") + List findAllByIdInWithLock(@Param("ids") List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..298686e9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,60 @@ +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.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + if (product.id() != null) { + return productJpaRepository.findById(product.id()) + .map(entity -> { + entity.updateFrom(product); + return productJpaRepository.save(entity).toDomain(); + }) + .orElseGet(() -> productJpaRepository.save(ProductEntity.from(product)).toDomain()); + } + ProductEntity entity = ProductEntity.from(product); + ProductEntity saved = productJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id) + .map(ProductEntity::toDomain); + } + + @Override + public Optional findByIdIncludingDeleted(Long id) { + return productJpaRepository.findById(id) + .map(ProductEntity::toDomain); + } + + @Override + public Page findAll(Long brandId, Pageable pageable) { + if (brandId == null) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable).map(ProductEntity::toDomain); + } + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable).map(ProductEntity::toDomain); + } + + @Override + public List findAllByIdInWithLock(List ids) { + return productJpaRepository.findAllByIdInWithLock(ids) + .stream().map(ProductEntity::toDomain).toList(); + } + + } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserEntity.java deleted file mode 100644 index 10b0d4dbe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserEntity.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.BaseEntity; -import com.loopers.domain.user.User; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -import java.time.LocalDate; - -@Entity -@Table(name = "users") -public class UserEntity extends BaseEntity { - - @Getter - @Column(name = "user_id", nullable = false, unique = true) - private String userId; - - @Column(nullable = false) - private String password; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private String email; - - @Column(name = "birth_date", nullable = false) - private LocalDate birthDate; - - protected UserEntity() {} - - public UserEntity(String userId, String password, String name, String email, LocalDate birthDate) { - this.userId = userId; - this.password = password; - this.name = name; - this.email = email; - this.birthDate = birthDate; - } - - public static UserEntity from(User user) { - return new UserEntity( - user.id().value(), - user.password().value(), - user.name().value(), - user.email().value(), - user.birthDate().value() - ); - } - - public User toDomain() { - return new User( - new UserId(userId), - Password.ofEncoded(password), - new Name(name), - new Email(email), - new BirthDate(birthDate) - ); - } - - public void updatePassword(String encodedPassword) { - this.password = encodedPassword; - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java deleted file mode 100644 index f10b05e2c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.user; - -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserJpaRepository extends JpaRepository { - - Optional findByUserId(String userId); - - boolean existsByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java deleted file mode 100644 index aa49021f7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.domain.user.vo.UserId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@RequiredArgsConstructor -@Repository -public class UserRepositoryImpl implements UserRepository { - - private final UserJpaRepository userJpaRepository; - - @Override - public Optional findByUserId(UserId userId) { - return userJpaRepository.findByUserId(userId.value()) - .map(com.loopers.infrastructure.user.UserEntity::toDomain); - } - - @Override - public boolean existsByUserId(UserId userId) { - return userJpaRepository.existsByUserId(userId.value()); - } - - @Override - public User save(User user) { - if (!user.password().isEncoded()) { - throw new CoreException(ErrorType.INTERNAL_ERROR, "비밀번호가 암호화되지 않았습니다"); - } - - Optional existingModel = userJpaRepository.findByUserId(user.id().value()); - - if (existingModel.isPresent()) { - UserEntity model = existingModel.get(); - model.updatePassword(user.password().value()); - return userJpaRepository.save(model).toDomain(); - } - - UserEntity newModel = com.loopers.infrastructure.user.UserEntity.from(user); - return userJpaRepository.save(newModel).toDomain(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 6116f8d81..9f2dc11b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -3,10 +3,11 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -23,8 +24,9 @@ import java.util.stream.Collectors; @RestControllerAdvice -@Slf4j public class ApiControllerAdvice { + private static final Logger log = LoggerFactory.getLogger(ApiControllerAdvice.class); + @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); @@ -32,8 +34,8 @@ public ResponseEntity> handle(CoreException e) { } @ExceptionHandler - public ResponseEntity> handle(UserValidationException e) { - log.warn("UserValidationException : {}", e.getMessage(), e); + public ResponseEntity> handle(MemberValidationException e) { + log.warn("MemberValidationException : {}", e.getMessage(), e); return failureResponse(ErrorType.BAD_REQUEST); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java new file mode 100644 index 000000000..9f9cbcf90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandController { + + private final BrandApplicationService brandApplicationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createBrand( + @Valid @RequestBody BrandDto.CreateBrandRequest request + ) { + Brand brand = brandApplicationService.create(request.toCommand()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandApplicationService.findById(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse updateBrand( + @PathVariable Long brandId, + @Valid @RequestBody BrandDto.UpdateBrandRequest request + ) { + Brand brand = brandApplicationService.update(brandId, request.toCommand()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandApplicationService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java new file mode 100644 index 000000000..8d23ef4c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.category.CategoryApplicationService; +import com.loopers.domain.category.Category; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.category.CategoryDto; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/categories") +public class AdminCategoryController { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_ADMIN = "loopers.admin"; + + private final CategoryApplicationService categoryApplicationService; + + @GetMapping + public ApiResponse listCategories( + @RequestHeader(value = HEADER_LDAP, required = false) String ldap, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + validateAdmin(ldap); + Pageable pageable = PageRequest.of(page, size); + Page categories = categoryApplicationService.list(pageable); + return ApiResponse.success(CategoryDto.CategoryListResponse.from(categories)); + } + + @GetMapping("/{categoryId}") + public ApiResponse getCategory( + @RequestHeader(value = HEADER_LDAP, required = false) String ldap, + @PathVariable Long categoryId + ) { + validateAdmin(ldap); + Category category = categoryApplicationService.findById(categoryId); + return ApiResponse.success(CategoryDto.CategoryResponse.from(category)); + } + + private void validateAdmin(String ldap) { + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 없습니다."); + } + if (!LDAP_ADMIN.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "관리자 권한이 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java new file mode 100644 index 000000000..2bc44cc22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.order.OrderDto; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderController { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_ADMIN = "loopers.admin"; + + private final OrderApplicationService orderApplicationService; + + @GetMapping + public ApiResponse listOrders( + @RequestHeader(value = HEADER_LDAP, required = false) String ldap, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + validateAdmin(ldap); + Pageable pageable = PageRequest.of(page, size); + Page orders = orderApplicationService.listAll(pageable); + return ApiResponse.success(OrderDto.OrderListResponse.from(orders)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @RequestHeader(value = HEADER_LDAP, required = false) String ldap, + @PathVariable Long orderId + ) { + validateAdmin(ldap); + Order order = orderApplicationService.getById(orderId, null, true); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @PatchMapping("/{orderId}/cancel") + public ApiResponse cancelOrder( + @RequestHeader(value = HEADER_LDAP, required = false) String ldap, + @PathVariable Long orderId + ) { + validateAdmin(ldap); + Order order = orderApplicationService.cancel(orderId, null, true); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + private void validateAdmin(String ldap) { + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 없습니다."); + } + if (!LDAP_ADMIN.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "관리자 권한이 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..64ae340c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/brands") +public class BrandController { + + private final BrandApplicationService brandApplicationService; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandApplicationService.findById(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java new file mode 100644 index 000000000..1f60ddcea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java @@ -0,0 +1,70 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.application.brand.command.UpdateBrandCommand; +import com.loopers.domain.brand.Brand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +public class BrandDto { + + @Builder + public record CreateBrandRequest( + @NotBlank(message = "브랜드 이름은 필수입니다") + @Size(max = 50, message = "브랜드 이름은 50자를 초과할 수 없습니다") + String name, + String description, + String imageUrl + ) { + @Override + public String toString() { + return "CreateBrandRequest[name=%s, description=%s, imageUrl=%s]" + .formatted(name, description, imageUrl); + } + + public CreateBrandCommand toCommand() { + return CreateBrandCommand.builder() + .name(name) + .description(description) + .imageUrl(imageUrl) + .build(); + } + } + + @Builder + public record UpdateBrandRequest( + String description, + String imageUrl + ) { + @Override + public String toString() { + return "UpdateBrandRequest[description=%s, imageUrl=%s]" + .formatted(description, imageUrl); + } + + public UpdateBrandCommand toCommand() { + return UpdateBrandCommand.builder() + .description(description) + .imageUrl(imageUrl) + .build(); + } + } + + @Builder + public record BrandResponse( + Long id, + String name, + String description, + String imageUrl + ) { + public static BrandResponse from(Brand brand) { + return BrandResponse.builder() + .id(brand.id()) + .name(brand.name().value()) + .description(brand.description()) + .imageUrl(brand.imageUrl()) + .build(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java new file mode 100644 index 000000000..7256b6a62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.category; + +import com.loopers.domain.category.Category; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class CategoryDto { + + public record CategoryResponse( + Long id, + String name + ) { + public static CategoryResponse from(Category category) { + return new CategoryResponse(category.id(), category.name()); + } + } + + public record CategoryListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static CategoryListResponse from(Page pageData) { + return new CategoryListResponse( + pageData.getContent().stream().map(CategoryResponse::from).toList(), + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PaginationQuery.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PaginationQuery.java new file mode 100644 index 000000000..0efb20dc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PaginationQuery.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public record PaginationQuery(Integer page, Integer size) { + public static final int DEFAULT_PAGE = 0; + public static final int DEFAULT_SIZE = 20; + public static final int MAX_SIZE = 100; + + public PaginationQuery { + if (page != null && page < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상이어야 합니다."); + } + if (size != null && size < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 1 이상이어야 합니다."); + } + if (size != null && size > MAX_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 " + MAX_SIZE + "을(를) 초과할 수 없습니다."); + } + } + + public int resolvedPage() { + return page != null ? page : DEFAULT_PAGE; + } + + public int resolvedSize() { + return size != null ? size : DEFAULT_SIZE; + } + + public Pageable toPageable(Sort sort) { + return PageRequest.of(resolvedPage(), resolvedSize(), sort == null ? Sort.unsorted() : sort); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java new file mode 100644 index 000000000..c40bb4b47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberApplicationService; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthMember; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberController { + + private final MemberApplicationService memberApplicationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register(@Valid @RequestBody MemberDto.RegisterRequest request) { + memberApplicationService.register(request.toCommand()); + return ApiResponse.success(); + } + + @GetMapping("/duplicate") + public ApiResponse checkDuplicateLoginId( + @RequestParam String loginId + ) { + boolean exists = memberApplicationService.checkDuplicateLoginId(loginId); + if (exists) { + return ApiResponse.success(MemberDto.DuplicateCheckResponse.unavailable(loginId)); + } + return ApiResponse.success(MemberDto.DuplicateCheckResponse.available(loginId)); + } + + @GetMapping("/me") + public ApiResponse getMe(@AuthMember Member member) { + return ApiResponse.success(MemberDto.MemberResponse.from(member)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthMember Member member, + @Valid @RequestBody MemberDto.ChangePasswordRequest request + ) { + memberApplicationService.changePassword(request.toCommand(member.id())); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberDto.java new file mode 100644 index 000000000..d4cf34114 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberDto.java @@ -0,0 +1,87 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.command.ChangePasswordCommand; +import com.loopers.application.member.command.RegisterCommand; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.vo.MemberId; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class MemberDto { + + public record RegisterRequest( + @NotBlank(message = "로그인 ID는 필수입니다") + String loginId, + @NotBlank(message = "비밀번호는 필수입니다") + String password, + @NotBlank(message = "이름은 필수입니다") + String name, + @NotBlank(message = "생년월일은 필수입니다") + @Pattern(regexp = "\\d{8}", message = "생년월일은 yyyyMMdd 형식이어야 합니다") + String birthDate, + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + String email, + @NotBlank(message = "전화번호는 필수입니다") + @Pattern(regexp = "010-\\d{4}-\\d{4}", message = "전화번호는 010-XXXX-XXXX 형식이어야 합니다") + String phone + ) { + @Override + public String toString() { + String maskedPhone = phone != null + ? phone.replaceAll("(\\d{3})-\\d{4}-(\\d{4})", "$1-****-$2") + : null; + return "RegisterRequest[loginId=%s, password=***, name=%s, birthDate=%s, email=%s, phone=%s]" + .formatted(loginId, name, birthDate, email, maskedPhone); + } + public RegisterCommand toCommand() { + return new RegisterCommand(loginId, password, name, email, birthDate, phone); + } + } + + public record MemberResponse( + String loginId, + String name, + String email, + String birthDate, + String phone + ) { + public static MemberResponse from(Member member) { + return new MemberResponse( + member.id().value(), + member.getMaskedName(), + member.email().value(), + member.birthDate().value().toString(), + member.phone() != null ? member.phone().value() : null + ); + } + } + + public record ChangePasswordRequest( + @NotBlank(message = "새 비밀번호는 필수입니다") + String newPassword + ) { + @Override + public String toString() { + return "ChangePasswordRequest[newPassword=***]"; + } + + public ChangePasswordCommand toCommand(MemberId memberId) { + return new ChangePasswordCommand(memberId, newPassword); + } + } + + public record DuplicateCheckResponse( + boolean available, + String loginId + ) { + public static DuplicateCheckResponse available(String loginId) { + return new DuplicateCheckResponse(true, loginId); + } + + public static DuplicateCheckResponse unavailable(String loginId) { + return new DuplicateCheckResponse(false, loginId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..672ed0d7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,85 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.domain.order.Order; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthMember; +import com.loopers.application.member.MemberAuthenticationService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderApplicationService orderApplicationService; + private final MemberAuthenticationService memberAuthenticationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createOrder( + @AuthMember Member member, + @Valid @RequestBody OrderDto.CreateOrderRequest request + ) { + Long userId = resolveUserId(member); + Order order = orderApplicationService.create(request.toCommand(userId)); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @PatchMapping("/{orderId}/cancel") + public ApiResponse cancelOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + Long userId = resolveUserId(member); + Order order = orderApplicationService.cancel(orderId, userId, false); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + Long userId = resolveUserId(member); + Order order = orderApplicationService.getById(orderId, userId, false); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping + public ApiResponse listOrders( + @AuthMember Member member, + @RequestParam @NotNull @DateTimeFormat(pattern = "yyyyMMdd") LocalDate startAt, + @RequestParam @NotNull @DateTimeFormat(pattern = "yyyyMMdd") LocalDate endAt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Long userId = resolveUserId(member); + Pageable pageable = PageRequest.of(page, size); + Page orders = orderApplicationService.listByUser(userId, startAt, endAt, pageable); + return ApiResponse.success(OrderDto.OrderListResponse.from(orders)); + } + + private Long resolveUserId(Member member) { + return memberAuthenticationService.findDbIdByMemberId(member.id()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java new file mode 100644 index 000000000..f55c26171 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java @@ -0,0 +1,96 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderDto { + + public record CreateOrderRequest( + @NotEmpty(message = "주문 항목은 1개 이상이어야 합니다") + @Valid + List items + ) { + public CreateOrderCommand toCommand(Long userId) { + List itemCommands = items.stream() + .map(i -> new CreateOrderCommand.OrderItemCommand(i.productId(), i.quantity())) + .toList(); + return new CreateOrderCommand(userId, itemCommands); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + @Min(value = 1, message = "수량은 1 이상이어야 합니다") + int quantity + ) {} + + public record OrderItemResponse( + Long id, + Long productId, + int quantity, + String snapshotProductName, + int snapshotPrice, + String snapshotBrandName + ) { + public static OrderItemResponse from(OrderItem item) { + return new OrderItemResponse( + item.id(), + item.productId(), + item.quantity(), + item.snapshotProductName(), + item.snapshotPrice(), + item.snapshotBrandName() + ); + } + } + + public record OrderResponse( + Long id, + Long userId, + String orderNumber, + ZonedDateTime orderDate, + String status, + int totalAmount, + List items + ) { + public static OrderResponse from(Order order) { + return new OrderResponse( + order.id(), + order.userId(), + order.orderNumber(), + order.orderDate(), + order.status().name(), + order.totalAmount(), + order.items().stream().map(OrderItemResponse::from).toList() + ); + } + } + + public record OrderListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static OrderListResponse from(Page pageData) { + return new OrderListResponse( + pageData.getContent().stream().map(OrderResponse::from).toList(), + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java new file mode 100644 index 000000000..cb792c0ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthMember; +import com.loopers.domain.member.Member; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class LikeController { + + private final LikeApplicationService likeApplicationService; + + public LikeController(LikeApplicationService likeApplicationService) { + this.likeApplicationService = likeApplicationService; + } + + @PostMapping("/products/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse registerLike(@PathVariable Long productId, @AuthMember Member member) { + likeApplicationService.register(productId, member); + return ApiResponse.success(); + } + + @DeleteMapping("/products/{productId}/likes") + public ApiResponse cancelLike(@PathVariable Long productId, @AuthMember Member member) { + likeApplicationService.cancel(productId, member); + return ApiResponse.success(); + } + + @GetMapping("/me/likes") + public ApiResponse getMyLikes( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthMember Member member + ) { + return ApiResponse.success(ProductDto.ProductListResponse.from( + likeApplicationService.getMyLikes(member.id().value(), PageRequest.of(page, size)) + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..5299340ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductApplicationService; +import com.loopers.domain.product.Product; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductApplicationService productApplicationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct( + @Valid @RequestBody ProductDto.CreateProductRequest request + ) { + Product created = productApplicationService.create(request.toCommand()); + return ApiResponse.success(ProductDto.ProductResponse.from(created)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + Product product = productApplicationService.get(productId); + return ApiResponse.success(ProductDto.ProductResponse.from(product)); + } + + @GetMapping + public ApiResponse getProducts(ProductListQuery query) { + Page products = productApplicationService.list(query.brandId(), query.toPageable()); + return ApiResponse.success(ProductDto.ProductListResponse.from(products)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java new file mode 100644 index 000000000..cb78a4e92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.domain.product.Product; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class ProductDto { + + public record CreateProductRequest( + @NotBlank(message = "상품명은 필수입니다") + String name, + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0 이상이어야 합니다") + Integer price, + @NotNull(message = "재고는 필수입니다") + @Min(value = 0, message = "재고는 0 이상이어야 합니다") + Integer stock, + String description, + @NotNull(message = "카테고리 ID는 필수입니다") + Long categoryId, + @NotNull(message = "브랜드 ID는 필수입니다") + Long brandId + ) { + public CreateProductCommand toCommand() { + return new CreateProductCommand(name, price, stock, description, categoryId, brandId); + } + } + + public record ProductResponse( + Long id, + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId, + Integer likeCount + ) { + public static ProductResponse from(Product product) { + return new ProductResponse( + product.id(), + product.name(), + product.price(), + product.stock(), + product.description(), + product.categoryId(), + product.brandId(), + product.likeCount() + ); + } + } + + public record ProductListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page pageData) { + List items = pageData.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new ProductListResponse( + items, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductListQuery.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductListQuery.java new file mode 100644 index 000000000..d45e978d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductListQuery.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.common.PaginationQuery; +import org.springframework.data.domain.Pageable; + +public record ProductListQuery( + Long brandId, + String sort, + Integer page, + Integer size +) { + + public ProductSortType resolvedSort() { + return ProductSortType.from(sort); + } + + public Pageable toPageable() { + return new PaginationQuery(page, size).toPageable(resolvedSort().toSort()); + } +} 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..d8396a3e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java @@ -0,0 +1,37 @@ +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("latest", Sort.by(Sort.Direction.DESC, "createdAt")), + PRICE_ASC("price_asc", Sort.by(Sort.Direction.ASC, "price")), + LIKES_DESC("likes_desc", Sort.by(Sort.Direction.DESC, "likeCount")); + + private final String value; + private final Sort sort; + + ProductSortType(String value, Sort sort) { + this.value = value; + this.sort = sort; + } + + public Sort toSort() { + return sort; + } + + public static ProductSortType from(String value) { + if (value == null || value.isBlank()) { + return LATEST; + } + + for (ProductSortType sortType : values()) { + if (sortType.value.equals(value)) { + return sortType; + } + } + + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 정렬 기준입니다: %s".formatted(value)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java deleted file mode 100644 index 767e27cee..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserFacade; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AuthUser; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/users") -public class UserController { - - private final UserService userService; - private final UserFacade userFacade; - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public ApiResponse register(@Valid @RequestBody UserDto.RegisterRequest request) { - userService.register(request.toCommand()); - return ApiResponse.success(); - } - - @GetMapping("/me") - public ApiResponse getMe(@AuthUser User user) { - return ApiResponse.success(UserDto.UserResponse.from(user)); - } - - @PatchMapping("/me/password") - public ApiResponse changePassword( - @AuthUser User user, - @Valid @RequestBody UserDto.ChangePasswordRequest request - ) { - userFacade.changePassword(request.toFacadeRequest(user.id())); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java deleted file mode 100644 index b1dd9df7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserFacadeDto; -import com.loopers.application.user.command.RegisterCommand; -import com.loopers.domain.user.User; -import com.loopers.domain.user.vo.UserId; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import lombok.Builder; - -public class UserDto { - - public record RegisterRequest( - @NotBlank(message = "로그인 ID는 필수입니다") - String loginId, - - @NotBlank(message = "비밀번호는 필수입니다") - String password, - - @NotBlank(message = "이름은 필수입니다") - String name, - - @NotBlank(message = "생년월일은 필수입니다") - @Pattern(regexp = "\\d{8}", message = "생년월일은 yyyyMMdd 형식이어야 합니다") - String birthDate, - - @NotBlank(message = "이메일은 필수입니다") - @Email(message = "올바른 이메일 형식이 아닙니다") - String email - ) { - public RegisterCommand toCommand() { - return RegisterCommand.builder() - .userId(loginId) - .rawPassword(password) - .name(name) - .email(email) - .birthDate(birthDate) - .build(); - } - } - - @Builder - public record UserResponse( - String loginId, - String name, - String email, - String birthDate - ) { - public static UserResponse from(User user) { - return UserResponse.builder() - .loginId(user.id().value()) - .name(user.getMaskedName()) - .email(user.email().value()) - .birthDate(user.birthDate().value().toString()) - .build(); - } - } - - public record ChangePasswordRequest( - @NotBlank(message = "현재 비밀번호는 필수입니다") - String currentPassword, - - @NotBlank(message = "새 비밀번호는 필수입니다") - String newPassword - ) { - public UserFacadeDto.ChangePasswordRequest toFacadeRequest(UserId userId) { - return UserFacadeDto.ChangePasswordRequest.builder() - .userId(userId) - .currentPassword(currentPassword) - .newPassword(newPassword) - .build(); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMember.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMember.java index 7048f257d..96728a7b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMember.java @@ -7,5 +7,5 @@ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) -public @interface AuthUser { +public @interface AuthMember { } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMemberArgumentResolver.java similarity index 60% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMemberArgumentResolver.java index 65f19c089..9ff7a6e70 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthMemberArgumentResolver.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.auth; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserAuthService; -import com.loopers.application.user.command.AuthenticateCommand; -import com.loopers.domain.user.vo.UserId; +import com.loopers.application.member.MemberAuthenticationService; +import com.loopers.application.member.command.AuthenticateCommand; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.vo.MemberId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; @@ -17,17 +17,17 @@ @RequiredArgsConstructor @Component -public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { +public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final UserAuthService userAuthService; + private final MemberAuthenticationService memberAuthenticationService; @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(AuthUser.class) - && parameter.getParameterType().equals(User.class); + return parameter.hasParameterAnnotation(AuthMember.class) + && parameter.getParameterType().equals(Member.class); } @Override @@ -37,7 +37,10 @@ public Object resolveArgument( NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) { - HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "HttpServletRequest를 가져올 수 없습니다"); + } String loginId = request.getHeader(HEADER_LOGIN_ID); String password = request.getHeader(HEADER_LOGIN_PW); @@ -45,10 +48,7 @@ public Object resolveArgument( throw new CoreException(ErrorType.UNAUTHORIZED); } - AuthenticateCommand command = AuthenticateCommand.builder() - .userId(new UserId(loginId)) - .rawPassword(password) - .build(); - return userAuthService.authenticate(command); + AuthenticateCommand command = new AuthenticateCommand(new MemberId(loginId), password); + return memberAuthenticationService.authenticate(command); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java index 8666e6f23..6fffdcedd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.config; -import com.loopers.interfaces.auth.AuthUserArgumentResolver; +import com.loopers.interfaces.auth.AuthMemberArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -12,10 +12,10 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - private final AuthUserArgumentResolver authUserArgumentResolver; + private final AuthMemberArgumentResolver authMemberArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(authUserArgumentResolver); + resolvers.add(authMemberArgumentResolver); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6b..50e868561 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -1,8 +1,5 @@ package com.loopers.support.error; -import lombok.Getter; - -@Getter public class CoreException extends RuntimeException { private final ErrorType errorType; private final String customMessage; @@ -16,4 +13,12 @@ public CoreException(ErrorType errorType, String customMessage) { this.errorType = errorType; this.customMessage = customMessage; } + + public ErrorType getErrorType() { + return errorType; + } + + public String getCustomMessage() { + return customMessage; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491a..e9e5b8b0b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -1,20 +1,35 @@ package com.loopers.support.error; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@Getter -@RequiredArgsConstructor public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "권한이 없습니다."); private final HttpStatus status; private final String code; private final String message; + + ErrorType(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java new file mode 100644 index 000000000..89a1c6849 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java @@ -0,0 +1,160 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.application.brand.command.UpdateBrandCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +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 org.mockito.InOrder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.inOrder; + +@ExtendWith(MockitoExtension.class) +class BrandApplicationServiceTest { + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private BrandApplicationService brandApplicationService; + + @Nested + @DisplayName("브랜드 등록") + class CreateBrandTest { + + @Test + @DisplayName("CreateBrandCommand 생성 성공") + void createCommandCreation() { + String name = "퍼피박스"; + String description = "강아지용품 브랜드"; + String imageUrl = "https://example.com/brand.png"; + + CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); + + assertThat(command.name()).isEqualTo(name); + assertThat(command.description()).isEqualTo(description); + assertThat(command.imageUrl()).isEqualTo(imageUrl); + } + + @Test + @DisplayName("CreateBrandCommand - description null 허용") + void createCommandWithNullDescription() { + String name = "퍼피박스"; + String description = null; + String imageUrl = "https://example.com/brand.png"; + + CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); + + assertThat(command.description()).isNull(); + } + + @Test + @DisplayName("CreateBrandCommand - imageUrl null 허용") + void createCommandWithNullImageUrl() { + String name = "퍼피박스"; + String description = "설명"; + String imageUrl = null; + + CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); + + assertThat(command.imageUrl()).isNull(); + } + } + + @Nested + @DisplayName("브랜드 수정") + class UpdateBrandTest { + + @Test + @DisplayName("UpdateBrandCommand 생성 - description만 변경") + void updateCommandDescriptionOnly() { + String description = "새로운 설명"; + String imageUrl = null; + + UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); + + assertThat(command.description()).isEqualTo(description); + assertThat(command.imageUrl()).isNull(); + } + + @Test + @DisplayName("UpdateBrandCommand 생성 - imageUrl만 변경") + void updateCommandImageUrlOnly() { + String description = null; + String imageUrl = "https://new.com/brand.png"; + + UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); + + assertThat(command.description()).isNull(); + assertThat(command.imageUrl()).isEqualTo(imageUrl); + } + + @Test + @DisplayName("UpdateBrandCommand 생성 - 둘 다 변경") + void updateCommandBoth() { + String description = "새로운 설명"; + String imageUrl = "https://new.com/brand.png"; + + UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); + + assertThat(command.description()).isEqualTo(description); + assertThat(command.imageUrl()).isEqualTo(imageUrl); + } + } + + @Nested + @DisplayName("브랜드 삭제") + class DeleteTest { + + @Test + @DisplayName("브랜드 삭제 시 관련 상품 soft-delete와 함께 삭제한다") + void deleteAlsoDeletesRelatedProducts() { + Brand target = new Brand(1L, new BrandName("퍼피박스"), "설명", "https://example.com/brand.png"); + + when(brandRepository.findById(1L)).thenReturn(Optional.of(target)); + doNothing().when(brandRepository).deleteRelatedProducts(1L); + doNothing().when(brandRepository).delete(target); + + brandApplicationService.delete(1L); + + InOrder inOrder = inOrder(brandRepository); + inOrder.verify(brandRepository).deleteRelatedProducts(1L); + inOrder.verify(brandRepository).delete(target); + verifyNoMoreInteractions(brandRepository); + } + + @Test + @DisplayName("삭제 대상이 없으면 404 반환") + void deleteWhenNotFound() { + when(brandRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> brandApplicationService.delete(99L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + + verify(brandRepository).findById(99L); + verify(brandRepository, never()).deleteRelatedProducts(anyLong()); + verify(brandRepository, never()).delete(any(Brand.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java new file mode 100644 index 000000000..33a7ff8ad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java @@ -0,0 +1,211 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.ChangePasswordCommand; +import com.loopers.application.member.command.RegisterCommand; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberApplicationServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private MemberApplicationService memberApplicationService; + + private MemberId memberId; + private Name name; + private Email email; + private BirthDate birthDate; + private Phone phone; + private Member member; + + @BeforeEach + void setUp() { + memberId = new MemberId("testmember"); + name = new Name("홍길동"); + email = new Email("test@example.com"); + birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); + phone = new Phone("010-1234-5678"); + member = new Member(memberId, Password.ofEncoded("$2a$10$encodedPassword"), name, email, birthDate, phone); + } + + @Nested + @DisplayName("회원가입") + class RegisterTest { + + @Test + @DisplayName("성공") + void registerSuccess() { + RegisterCommand command = RegisterCommand.builder() + .memberId("testmember") + .rawPassword("1Q2w3e4r!") + .name("홍길동") + .email("test@example.com") + .birthDate("19990115") + .phone("010-1234-5678") + .build(); + Member encodedMember = new Member(memberId, Password.ofEncoded("$2a$10$encodedPassword"), name, email, birthDate, phone); + + when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(false); + when(passwordEncoder.encode("1Q2w3e4r!")).thenReturn("$2a$10$encodedPassword"); + when(memberRepository.save(encodedMember)).thenReturn(encodedMember); + + Member result = memberApplicationService.register(command); + + assertThat(result.id().value()).isEqualTo("testmember"); + assertThat(result.password().value()).isEqualTo("$2a$10$encodedPassword"); + assertThat(result.phone().value()).isEqualTo("010-1234-5678"); + } + + @Test + @DisplayName("실패 - 로그인 ID 중복") + void registerFailDuplicateMemberId() { + RegisterCommand command = RegisterCommand.builder() + .memberId("testmember") + .rawPassword("1Q2w3e4r!") + .name("홍길동") + .email("test@example.com") + .birthDate("19990115") + .phone("010-1234-5678") + .build(); + when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(true); + + assertThatThrownBy(() -> memberApplicationService.register(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + verify(memberRepository, never()).save(any(Member.class)); + } + + @Test + @DisplayName("실패 - 저장 시점 중복") + void registerFailDuplicateMemberIdAtSave() { + RegisterCommand command = RegisterCommand.builder() + .memberId("testmember") + .rawPassword("1Q2w3e4r!") + .name("홍길동") + .email("test@example.com") + .birthDate("19990115") + .phone("010-1234-5678") + .build(); + when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(false); + when(passwordEncoder.encode("1Q2w3e4r!")).thenReturn("$2a$10$encodedPassword"); + when(memberRepository.save(any(Member.class))).thenThrow(new DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> memberApplicationService.register(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + } + + @Nested + @DisplayName("로그인 ID 중복 검사") + class DuplicateCheckTest { + + @Test + @DisplayName("사용 가능한 아이디 - false 반환") + void checkDuplicateLoginId_available() { + when(memberRepository.existsByMemberId(memberId)).thenReturn(false); + + boolean result = memberApplicationService.checkDuplicateLoginId("testmember"); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("이미 사용 중인 아이디 - true 반환") + void checkDuplicateLoginId_unavailable() { + when(memberRepository.existsByMemberId(memberId)).thenReturn(true); + + boolean result = memberApplicationService.checkDuplicateLoginId("testmember"); + + assertThat(result).isTrue(); + } + } + + @Nested + @DisplayName("비밀번호 변경") + class ChangePasswordTest { + + @Test + @DisplayName("성공") + void changePasswordSuccess() { + ChangePasswordCommand command = ChangePasswordCommand.builder() + .memberId(memberId) + .newRawPassword("New1234!@") + .build(); + Member updatedMember = new Member(memberId, Password.ofEncoded("$2a$10$newEncodedPassword"), name, email, birthDate, phone); + + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("New1234!@", "$2a$10$encodedPassword")).thenReturn(false); + when(passwordEncoder.encode("New1234!@")).thenReturn("$2a$10$newEncodedPassword"); + + assertThatNoException().isThrownBy(() -> memberApplicationService.changePassword(command)); + verify(memberRepository).save(updatedMember); + } + + @Test + @DisplayName("실패 - 존재하지 않는 사용자") + void changePasswordFailMemberNotFound() { + ChangePasswordCommand command = ChangePasswordCommand.builder() + .memberId(memberId) + .newRawPassword("New1234!@") + .build(); + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> memberApplicationService.changePassword(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + verify(memberRepository, never()).save(any(Member.class)); + } + + @Test + @DisplayName("실패 - 새 비밀번호가 기존과 동일") + void changePasswordFailSamePassword() { + ChangePasswordCommand command = ChangePasswordCommand.builder() + .memberId(memberId) + .newRawPassword("same") + .build(); + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("same", "$2a$10$encodedPassword")).thenReturn(true); + + assertThatThrownBy(() -> memberApplicationService.changePassword(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + verify(memberRepository, never()).save(any(Member.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java new file mode 100644 index 000000000..9e9cc914d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java @@ -0,0 +1,107 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.AuthenticateCommand; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberAuthenticationServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private MemberAuthenticationService memberAuthenticationService; + + private MemberId memberId; + private Member member; + + @BeforeEach + void setUp() { + memberId = new MemberId("testmember"); + member = new Member( + memberId, + Password.ofEncoded("$2a$10$dummyEncodedPasswordForTest"), + new Name("홍길동"), + new Email("test@example.com"), + new BirthDate(LocalDate.of(1999, 1, 15)), + new Phone("010-1234-5678") + ); + } + + @Nested + @DisplayName("인증") + class AuthenticateTest { + + @Test + @DisplayName("성공") + void authenticateSuccess() { + AuthenticateCommand command = AuthenticateCommand.builder() + .memberId(memberId) + .rawPassword("1Q2w3e4r!") + .build(); + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("1Q2w3e4r!", member.password().value())).thenReturn(true); + + Member result = memberAuthenticationService.authenticate(command); + + assertThat(result.id()).isEqualTo(memberId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 사용자") + void authenticateFailMemberNotFound() { + AuthenticateCommand command = AuthenticateCommand.builder() + .memberId(memberId) + .rawPassword("1Q2w3e4r!") + .build(); + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> memberAuthenticationService.authenticate(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @Test + @DisplayName("실패 - 비밀번호 불일치") + void authenticateFailWrongPassword() { + AuthenticateCommand command = AuthenticateCommand.builder() + .memberId(memberId) + .rawPassword("wrongPassword") + .build(); + when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("wrongPassword", member.password().value())).thenReturn(false); + + assertThatThrownBy(() -> memberAuthenticationService.authenticate(command)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java new file mode 100644 index 000000000..09ea18672 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -0,0 +1,189 @@ +package com.loopers.application.order; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import 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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderApplicationServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private OrderApplicationService orderApplicationService; + + private static final Brand SAMPLE_BRAND = new Brand(10L, new BrandName("퍼피박스"), "설명", "http://img.com"); + private static final Product SAMPLE_PRODUCT = new Product(1L, "강아지 사료", 10000, 20, "desc", 1L, 10L, 0, null); + + @Nested + @DisplayName("주문 생성") + class Create { + + @Test + @DisplayName("유효한 상품과 재고로 주문 생성 성공") + void createOrderSuccess() { + CreateOrderCommand command = new CreateOrderCommand( + 1L, + List.of(new CreateOrderCommand.OrderItemCommand(1L, 2)) + ); + + when(productRepository.findAllByIdInWithLock(List.of(1L))) + .thenReturn(List.of(SAMPLE_PRODUCT)); + when(brandRepository.findById(10L)) + .thenReturn(Optional.of(SAMPLE_BRAND)); + when(productRepository.save(any(Product.class))) + .thenReturn(SAMPLE_PRODUCT.decreaseStock(2)); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderApplicationService.create(command); + + assertThat(result.status()).isEqualTo(OrderStatus.ORDERED); + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.items()).hasSize(1); + } + + @Test + @DisplayName("주문 항목이 비어있으면 400 예외가 발생한다") + void emptyItemsFails() { + CreateOrderCommand command = new CreateOrderCommand(1L, List.of()); + + assertThatThrownBy(() -> orderApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("존재하지 않는 상품을 주문하면 404 예외가 발생한다") + void nonExistentProductFails() { + CreateOrderCommand command = new CreateOrderCommand( + 1L, + List.of(new CreateOrderCommand.OrderItemCommand(999L, 1)) + ); + + when(productRepository.findAllByIdInWithLock(anyList())).thenReturn(List.of()); + + assertThatThrownBy(() -> orderApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @Test + @DisplayName("이미 취소된 주문 재취소는 409 예외가 발생한다") + void cancelAlreadyCancelledOrderFails() { + Order cancelledOrder = new Order( + 1L, 1L, "ORDER-001", + java.time.ZonedDateTime.now(), + OrderStatus.CANCELLED, + 10000, + List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), + null + ); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(cancelledOrder)); + + assertThatThrownBy(() -> orderApplicationService.cancel(1L, 1L, false)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + + @Test + @DisplayName("타인의 주문 취소 시 403 예외가 발생한다") + void cancelOthersOrderFails() { + Order order = new Order( + 1L, 2L, "ORDER-001", + java.time.ZonedDateTime.now(), + OrderStatus.ORDERED, + 10000, + List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), + null + ); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderApplicationService.cancel(1L, 1L, false)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + + @Test + @DisplayName("존재하지 않는 주문 취소 시 404 예외가 발생한다") + void cancelNonExistentOrderFails() { + when(orderRepository.findById(anyLong())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderApplicationService.cancel(99L, 1L, false)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("주문 상세 조회") + class GetById { + + @Test + @DisplayName("타인의 주문 조회 시 403 예외가 발생한다") + void getOthersOrderFails() { + Order order = new Order( + 1L, 2L, "ORDER-001", + java.time.ZonedDateTime.now(), + OrderStatus.ORDERED, + 10000, + List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), + null + ); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderApplicationService.getById(1L, 1L, false)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 시 404 예외가 발생한다") + void getNonExistentOrderFails() { + when(orderRepository.findById(anyLong())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderApplicationService.getById(99L, 1L, false)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java new file mode 100644 index 000000000..a32733307 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java @@ -0,0 +1,92 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Like Domain Tests") +class LikeServiceTest { + + private static final Product ACTIVE_PRODUCT = new Product( + 1L, + "간식", + 12_000, + 10, + "닭가슴살 간식", + 1L, + 1L, + 3, + null + ); + + @Nested + @DisplayName("좋아요 카운트") + class LikeCount { + + @Test + @DisplayName("좋아요 등록이면 likeCount가 1 증가한다") + void increaseLikeCount_whenRegisterLike_thenLikeCountIncreasesByOne() { + Product result = ACTIVE_PRODUCT.increaseLikeCount(); + assertThat(result.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() + 1); + } + + @Test + @DisplayName("좋아요가 중복 등록되어도 증가 로직은 호출 횟수만큼 반영된다") + void increaseLikeCount_whenRegisteredTwice_thenLikeCountIncreasesTwice() { + Product once = ACTIVE_PRODUCT.increaseLikeCount(); + Product twice = once.increaseLikeCount(); + + assertThat(twice.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() + 2); + } + + @Test + @DisplayName("좋아요 취소하면 likeCount가 1 감소한다") + void decreaseLikeCount_whenCancelLike_thenLikeCountDecreasesByOne() { + Product result = ACTIVE_PRODUCT.decreaseLikeCount(); + + assertThat(result.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() - 1); + } + + @Test + @DisplayName("이미 0인 likeCount는 취소 시 음수로 내려가지 않는다") + void decreaseLikeCount_whenLikeCountZero_thenKeepsZero() { + Product zeroLiked = new Product( + 2L, + "우산", + 20_000, + 5, + "산책 우산", + 1L, + 1L, + 0, + null + ); + + Product result = zeroLiked.decreaseLikeCount(); + assertThat(result.likeCount()).isEqualTo(0); + } + + @Test + @DisplayName("삭제 상품이면 삭제 상태를 판단할 수 있다") + void isDeleted_whenDeletedAtExists_thenReturnsTrue() { + Product deletedProduct = new Product( + 3L, + "하네스", + 15_000, + 3, + "산책 하네스", + 1L, + 1L, + 5, + ZonedDateTime.now() + ); + + assertThat(deletedProduct.isDeleted()).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java new file mode 100644 index 000000000..769a5f161 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java @@ -0,0 +1,187 @@ +package com.loopers.application.product; + +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductApplicationServiceTest { + + @Mock + private ProductRepository productRepository; + @Mock + private BrandRepository brandRepository; + @Mock + private CategoryRepository categoryRepository; + + @InjectMocks + private ProductApplicationService productApplicationService; + + @Nested + @DisplayName("상품 등록") + class CreateTest { + + @Test + @DisplayName("성공") + void createSuccess() { + CreateProductCommand command = new CreateProductCommand( + "강아지 사료", + 10000, + 20, + "소형견용", + 1L, + 1L + ); + when(brandRepository.findById(1L)).thenReturn(Optional.of(new Brand(1L, new BrandName("퍼피박스"), "", ""))); + when(categoryRepository.findById(1L)).thenReturn(Optional.of(new Category(1L, "푸드"))); + + Product saved = new Product(1L, "강아지 사료", 10000, 20, "소형견용", 1L, 1L, 0, null); + when(productRepository.save(any(Product.class))).thenReturn(saved); + + Product result = productApplicationService.create(command); + + assertThat(result.id()).isEqualTo(1L); + assertThat(result.name()).isEqualTo("강아지 사료"); + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("실패 - 브랜드가 존재하지 않음") + void createFailWhenBrandNotFound() { + CreateProductCommand command = new CreateProductCommand( + "강아지 사료", + 10000, + 20, + "소형견용", + 1L, + 1L + ); + + when(brandRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> productApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("실패 - 카테고리가 존재하지 않음") + void createFailWhenCategoryNotFound() { + CreateProductCommand command = new CreateProductCommand( + "강아지 사료", + 10000, + 20, + "소형견용", + 1L, + 1L + ); + + when(brandRepository.findById(1L)).thenReturn(Optional.of(new Brand(1L, new BrandName("퍼피박스"), "", ""))); + when(categoryRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> productApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("실패 - 카테고리 ID 누락") + void createFailWhenCategoryIdMissing() { + CreateProductCommand command = new CreateProductCommand( + "강아지 사료", + 10000, + 20, + "소형견용", + null, + 10L + ); + + assertThatThrownBy(() -> productApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("실패 - 브랜드 ID 누락") + void createFailWhenBrandIdMissing() { + CreateProductCommand command = new CreateProductCommand( + "강아지 사료", + 10000, + 20, + "소형견용", + 1L, + null + ); + + assertThatThrownBy(() -> productApplicationService.create(command)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + verify(productRepository, never()).save(any(Product.class)); + } + } + + @Nested + @DisplayName("상품 조회") + class GetTest { + + @Test + @DisplayName("실패 - 존재하지 않는 상품") + void getFailNotFound() { + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> productApplicationService.get(999L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @Nested + @DisplayName("상품 목록 조회") + class ListTest { + + @Test + @DisplayName("브랜드 필터로 조회한다") + void listByBrand() { + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of( + new Product(1L, "A", 1000, 5, "d1", 1L, 10L, 0, null) + )); + when(productRepository.findAll(10L, pageable)).thenReturn(page); + + Page result = productApplicationService.list(10L, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).brandId()).isEqualTo(10L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java new file mode 100644 index 000000000..6987da34c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -0,0 +1,30 @@ +package com.loopers.application.product; + +import org.junit.jupiter.api.Disabled; +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; + +@Disabled("Scaffold: enable after Product module implementation is added") +@DisplayName("Product Application Service Tests") +class ProductServiceTest { + + @Nested + @DisplayName("상품 등록") + class Register { + + @Test + @DisplayName("상품등록_성공") + void registerSuccess() { + assertThat(true).isTrue(); + } + + @Test + @DisplayName("상품등록_브랜드미존재_예외") + void registerFailWhenBrandNotFound() { + assertThat(true).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..a950b4123 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,137 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.exception.BrandValidationException; +import com.loopers.domain.brand.vo.BrandName; +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.*; + +public class BrandTest { + + @Nested + @DisplayName("Brand 생성") + class BrandCreationTest { + + @Test + @DisplayName("Brand 생성 성공") + void createBrandSuccess() { + // given + BrandName name = new BrandName("퍼피박스"); + String description = "강아지용품 브랜드"; + String imageUrl = "https://example.com/brand.png"; + + // when & then + assertThatNoException() + .isThrownBy(() -> new Brand(name, description, imageUrl)); + } + + @Test + @DisplayName("Brand 생성 성공 - description null 허용") + void createBrandWithNullDescription() { + // given + BrandName name = new BrandName("퍼피박스"); + String description = null; + String imageUrl = "https://example.com/brand.png"; + + // when & then + assertThatNoException() + .isThrownBy(() -> new Brand(name, description, imageUrl)); + } + + @Test + @DisplayName("Brand 생성 성공 - imageUrl null 허용") + void createBrandWithNullImageUrl() { + // given + BrandName name = new BrandName("퍼피박스"); + String description = "강아지용품 브랜드"; + String imageUrl = null; + + // when & then + assertThatNoException() + .isThrownBy(() -> new Brand(name, description, imageUrl)); + } + } + + @Nested + @DisplayName("Brand 수정") + class BrandUpdateTest { + + @Test + @DisplayName("description 수정") + void updateDescription() { + // given + BrandName name = new BrandName("퍼피박스"); + Brand brand = new Brand(name, "기존 설명", "https://old.com/img.png"); + + // when + Brand updated = brand.updateDescription("새로운 설명"); + + // then + assertThat(updated.name()).isEqualTo(brand.name()); + assertThat(updated.description()).isEqualTo("새로운 설명"); + assertThat(updated.imageUrl()).isEqualTo(brand.imageUrl()); + } + + @Test + @DisplayName("imageUrl 수정") + void updateImageUrl() { + // given + BrandName name = new BrandName("퍼피박스"); + Brand brand = new Brand(name, "설명", "https://old.com/img.png"); + + // when + Brand updated = brand.updateImageUrl("https://new.com/img.png"); + + // then + assertThat(updated.name()).isEqualTo(brand.name()); + assertThat(updated.description()).isEqualTo(brand.description()); + assertThat(updated.imageUrl()).isEqualTo("https://new.com/img.png"); + } + + @Test + @DisplayName("description과 imageUrl 동시 수정") + void updateDescriptionAndImageUrl() { + // given + BrandName name = new BrandName("퍼피박스"); + Brand brand = new Brand(name, "기존 설명", "https://old.com/img.png"); + + // when + Brand updated = brand.update("새로운 설명", "https://new.com/img.png"); + + // then + assertThat(updated.name().value()).isEqualTo("퍼피박스"); + assertThat(updated.description()).isEqualTo("새로운 설명"); + assertThat(updated.imageUrl()).isEqualTo("https://new.com/img.png"); + } + + @Test + @DisplayName("name은 불변 - 수정 시도 시 예외") + void nameIsImmutable() { + // given + BrandName name = new BrandName("퍼피박스"); + Brand brand = new Brand(name, "설명", "https://example.com/img.png"); + + // when & then + // Brand는 Record이므로 name 필드는 불변 + // name을 수정하려면 새로운 Brand 객체를 생성해야 함 + assertThat(brand.name().value()).isEqualTo("퍼피박스"); + } + } + + @Nested + @DisplayName("Brand 삭제 가능 조건") + class BrandDeleteTest { + @Test + @DisplayName("삭제 가능 - 연관 데이터 무관") + void canDeleteRegardlessOfRelatedData() { + BrandName name = new BrandName("퍼피박스"); + Brand brand = new Brand(name, "설명", "https://example.com/img.png"); + + boolean canDelete = brand.canDelete(); + + assertThat(canDelete).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java new file mode 100644 index 000000000..30178cc36 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java @@ -0,0 +1,89 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.domain.brand.exception.BrandValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.reflect.Modifier; + +import static org.assertj.core.api.Assertions.*; + +public class BrandNameTest { + + @Nested + @DisplayName("BrandName 생성 검증") + class BrandNameValidationTest { + + @ParameterizedTest + @DisplayName("유효한 브랜드 이름") + @ValueSource(strings = {"퍼피박스", "도그월드", "A", "Brand123", "abc"}) + void validBrandName(String name) { + // when & then + assertThatNoException() + .isThrownBy(() -> new BrandName(name)); + } + + @ParameterizedTest + @DisplayName("브랜드 이름 null 또는 빈 문자열") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void nullOrEmptyBrandName(String name) { + // when & then + assertThatThrownBy(() -> new BrandName(name)) + .isInstanceOf(BrandValidationException.class); + } + + @Test + @DisplayName("브랜드 이름 길이 제한 - 50자 초과") + void brandNameExceeds50Chars() { + // given + String name = "a".repeat(51); + + // when & then + assertThatThrownBy(() -> new BrandName(name)) + .isInstanceOf(BrandValidationException.class); + } + + @Test + @DisplayName("브랜드 이름 길이 경계값 - 50자 성공") + void brandNameExactly50Chars() { + // given + String name = "a".repeat(50); + + // when & then + assertThatNoException() + .isThrownBy(() -> new BrandName(name)); + } + + @Test + @DisplayName("브랜드 이름 특수문자 포함 - 허용") + void brandNameWithSpecialCharsAllowed() { + // given - 하이픈, 언더스코어,ドット 허용 + String name = "Dog-Care_Shop.ko"; + + // when & then + assertThatNoException() + .isThrownBy(() -> new BrandName(name)); + } + } + + @Nested + @DisplayName("BrandName 불변성") + class BrandNameImmutabilityTest { + + @Test + @DisplayName("BrandName은 불변 객체") + void brandNameIsImmutable() { + // given + BrandName name = new BrandName("퍼피박스"); + + // when & then - value는 변경 불가 + assertThat(name.value()).isEqualTo("퍼피박스"); + assertThat(Modifier.isFinal(BrandName.class.getModifiers())).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java similarity index 52% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 95a8e4aef..88ae1272a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -1,11 +1,12 @@ -package com.loopers.domain.user; - -import com.loopers.domain.user.exception.UserValidationException; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; +package com.loopers.domain.member; + +import com.loopers.domain.member.exception.MemberValidationException; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.vo.Phone; +import com.loopers.domain.member.vo.MemberId; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -15,21 +16,22 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -public class UserTest { +public class MemberTest { @Nested - @DisplayName("User 생성") - class UserCreationTest { + @DisplayName("Member 생성") + class MemberCreationTest { @Test - @DisplayName("User 생성 성공") - void createUserSuccess() { + @DisplayName("Member 생성 성공") + void createMemberSuccess() { // given - UserId userId = mock(UserId.class); + MemberId memberId = mock(MemberId.class); Password password = mock(Password.class); Name name = mock(Name.class); Email email = mock(Email.class); BirthDate birthDate = mock(BirthDate.class); + Phone phone = mock(Phone.class); when(password.value()).thenReturn("1Q2w3e4r!"); when(birthDate.toYymmdd()).thenReturn("990115"); @@ -38,7 +40,7 @@ void createUserSuccess() { // when & then assertThatNoException() - .isThrownBy(() -> new User(userId, password, name, email, birthDate)); + .isThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)); } } @@ -50,15 +52,16 @@ class PasswordBirthDateValidationTest { @DisplayName("비밀번호에 생년월일(YYMMDD) 포함 시 실패") void passwordContainsYymmdd() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("Qw990115!"); Name name = new Name("홍길동"); Email email = new Email("test@example.com"); BirthDate birthDate = BirthDate.of("19990115"); + Phone phone = new Phone("010-1234-5678"); // when & then - assertThatThrownBy(() -> new User(userId, password, name, email, birthDate)) - .isInstanceOf(UserValidationException.class) + assertThatThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)) + .isInstanceOf(MemberValidationException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -66,15 +69,16 @@ void passwordContainsYymmdd() { @DisplayName("비밀번호에 생년월일(MMDD) 포함 시 실패") void passwordContainsMmdd() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Qwer0115!"); Name name = new Name("홍길동"); Email email = new Email("test@example.com"); BirthDate birthDate = BirthDate.of("19990115"); + Phone phone = new Phone("010-1234-5678"); // when & then - assertThatThrownBy(() -> new User(userId, password, name, email, birthDate)) - .isInstanceOf(UserValidationException.class) + assertThatThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)) + .isInstanceOf(MemberValidationException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -82,15 +86,16 @@ void passwordContainsMmdd() { @DisplayName("비밀번호에 생년월일(DDMM) 포함 시 실패") void passwordContainsDdmm() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Qwer1501!"); Name name = new Name("홍길동"); Email email = new Email("test@example.com"); BirthDate birthDate = BirthDate.of("19990115"); + Phone phone = new Phone("010-1234-5678"); // when & then - assertThatThrownBy(() -> new User(userId, password, name, email, birthDate)) - .isInstanceOf(UserValidationException.class) + assertThatThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)) + .isInstanceOf(MemberValidationException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -98,15 +103,32 @@ void passwordContainsDdmm() { @DisplayName("비밀번호에 생년월일 미포함 시 성공") void passwordNotContainsBirthDate() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Q2w3e4r!"); Name name = new Name("홍길동"); Email email = new Email("test@example.com"); BirthDate birthDate = BirthDate.of("19990115"); + Phone phone = new Phone("010-1234-5678"); // when & then assertThatNoException() - .isThrownBy(() -> new User(userId, password, name, email, birthDate)); + .isThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)); + } + + @Test + @DisplayName("인코딩된 비밀번호는 생년월일 포함 검증을 생략한다") + void skipBirthDateValidationWhenPasswordEncoded() { + // given + MemberId memberId = new MemberId("testmember"); + Password password = Password.ofEncoded("$2a$10$encodedPassword"); + Name name = new Name("홍길동"); + Email email = new Email("test@example.com"); + BirthDate birthDate = BirthDate.of("19990115"); + Phone phone = new Phone("010-1234-5678"); + + // when & then + assertThatNoException() + .isThrownBy(() -> new Member(memberId, password, name, email, birthDate, phone)); } } @@ -118,16 +140,17 @@ class MaskingTest { @DisplayName("한글 이름 마스킹 - 3글자") void getMaskedKoreanName() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Q2w3e4r!"); Name name = new Name("홍길동"); Email email = new Email("test@example.com"); BirthDate birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); + Phone phone = new Phone("010-1234-5678"); - User user = new User(userId, password, name, email, birthDate); + Member member = new Member(memberId, password, name, email, birthDate, phone); // when - String maskedName = user.getMaskedName(); + String maskedName = member.getMaskedName(); // then assertThat(maskedName).isEqualTo("홍길*"); @@ -137,16 +160,17 @@ void getMaskedKoreanName() { @DisplayName("영문 이름 마스킹") void getMaskedEnglishName() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Q2w3e4r!"); Name name = new Name("John"); Email email = new Email("test@example.com"); BirthDate birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); + Phone phone = new Phone("010-1234-5678"); - User user = new User(userId, password, name, email, birthDate); + Member member = new Member(memberId, password, name, email, birthDate, phone); // when - String maskedName = user.getMaskedName(); + String maskedName = member.getMaskedName(); // then assertThat(maskedName).isEqualTo("Joh*"); @@ -156,19 +180,40 @@ void getMaskedEnglishName() { @DisplayName("한 글자 이름 마스킹") void getMaskedSingleCharName() { // given - UserId userId = new UserId("testuser"); + MemberId memberId = new MemberId("testmember"); Password password = new Password("1Q2w3e4r!"); Name name = new Name("김"); Email email = new Email("test@example.com"); BirthDate birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); + Phone phone = new Phone("010-1234-5678"); - User user = new User(userId, password, name, email, birthDate); + Member member = new Member(memberId, password, name, email, birthDate, phone); // when - String maskedName = user.getMaskedName(); + String maskedName = member.getMaskedName(); // then assertThat(maskedName).isEqualTo("*"); } + + @Test + @DisplayName("두 글자 이름 마스킹") + void getMaskedTwoCharName() { + // given + MemberId memberId = new MemberId("testmember"); + Password password = new Password("1Q2w3e4r!"); + Name name = new Name("홍길"); + Email email = new Email("test@example.com"); + BirthDate birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); + Phone phone = new Phone("010-1234-5678"); + + Member member = new Member(memberId, password, name, email, birthDate, phone); + + // when + String maskedName = member.getMaskedName(); + + // then + assertThat(maskedName).isEqualTo("홍*"); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java similarity index 88% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/vo/BirthDateTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java index aed224eda..a31d77f56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -41,7 +41,7 @@ void validBirthDateLocalDate() { void emptyOrNullBirthDate(String dateString) { // when & then assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @@ -49,7 +49,7 @@ void emptyOrNullBirthDate(String dateString) { void nullLocalDate() { // when & then assertThatThrownBy(() -> new BirthDate(null)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -58,7 +58,7 @@ void nullLocalDate() { void invalidFormat(String dateString) { // when & then assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -67,7 +67,7 @@ void invalidFormat(String dateString) { void invalidDate(String dateString) { // when & then assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @@ -78,7 +78,7 @@ void futureDate() { // when & then assertThatThrownBy(() -> new BirthDate(futureDate)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java similarity index 67% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/vo/EmailTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java index 32349ec85..464609ba7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -19,15 +19,15 @@ class EmailValidationTest { @ParameterizedTest @DisplayName("유효한 이메일 형식") @ValueSource(strings = { - "user@example.com", - "user.name@example.com", - "user+tag@example.com", - "user-name@example.com", - "user_name@example.com", - "user123@example.com", - "123user@example.com", - "user@subdomain.example.com", - "user@example.co.kr", + "member@example.com", + "member.name@example.com", + "member+tag@example.com", + "member-name@example.com", + "member_name@example.com", + "member123@example.com", + "123member@example.com", + "member@subdomain.example.com", + "member@example.co.kr", "a@b.co" }) void validEmail(String email) { @@ -40,16 +40,16 @@ void validEmail(String email) { @DisplayName("이메일 형식 오류 - '@' 누락") void emailWithoutAtSign() { // when & then - assertThatThrownBy(() -> new Email("userexample.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("memberexample.com")) + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - 도메인 부분 누락") void emailWithoutDomain() { // when & then - assertThatThrownBy(() -> new Email("user@")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@")) + .isInstanceOf(MemberValidationException.class); } @Test @@ -57,65 +57,65 @@ void emailWithoutDomain() { void emailWithoutLocalPart() { // when & then assertThatThrownBy(() -> new Email("@example.com")) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - 도메인에 '.' 누락") void emailWithoutDotInDomain() { // when & then - assertThatThrownBy(() -> new Email("user@examplecom")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@examplecom")) + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - TLD 누락 (마지막 '.' 뒤에 아무것도 없음)") void emailWithoutTld() { // when & then - assertThatThrownBy(() -> new Email("user@example.")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@example.")) + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @DisplayName("이메일 형식 오류 - 로컬 부분에 허용되지 않는 특수문자 포함") @ValueSource(strings = { - "user name@example.com", - "user@example.com", - "user[name]@example.com", - "user\\name@example.com", - "user\"name@example.com", - "user,name@example.com", - "user;name@example.com", - "user:name@example.com" + "member name@example.com", + "member@example.com", + "member[name]@example.com", + "member\\name@example.com", + "member\"name@example.com", + "member,name@example.com", + "member;name@example.com", + "member:name@example.com" }) void emailWithInvalidSpecialCharsInLocalPart(String email) { // when & then assertThatThrownBy(() -> new Email(email)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - 연속된 '.' 포함") void emailWithConsecutiveDots() { // when & then - assertThatThrownBy(() -> new Email("user..name@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member..name@example.com")) + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - 로컬 부분이 '.'으로 시작") void emailStartsWithDot() { // when & then - assertThatThrownBy(() -> new Email(".user@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email(".member@example.com")) + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - 로컬 부분이 '.'으로 끝남") void emailLocalPartEndsWithDot() { // when & then - assertThatThrownBy(() -> new Email("user.@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member.@example.com")) + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -125,15 +125,15 @@ void emailLocalPartEndsWithDot() { void emptyOrNullEmail(String email) { // when & then assertThatThrownBy(() -> new Email(email)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @DisplayName("이메일 형식 오류 - '@'가 여러 개") void emailWithMultipleAtSigns() { // when & then - assertThatThrownBy(() -> new Email("user@@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@@example.com")) + .isInstanceOf(MemberValidationException.class); } @Test @@ -144,7 +144,7 @@ void emailLocalPartExceeds64Chars() { // when & then assertThatThrownBy(() -> new Email(longLocalPart)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @@ -155,7 +155,7 @@ void emailExceeds254Chars() { // when & then assertThatThrownBy(() -> new Email(longEmail)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java new file mode 100644 index 000000000..0d344ee88 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.exception.MemberValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +public class MemberIdTest { + + @Nested + @DisplayName("아이디 형식 검증") + class MemberIdValidationTest { + + @ParameterizedTest + @DisplayName("유효한 아이디 형식") + @ValueSource(strings = { + "member", + "member1", + "member123", + "Member123", + "a1234", + "abcd", + "member1234567890abcd" + }) + void validMemberId(String memberId) { + // when & then + assertThatNoException() + .isThrownBy(() -> new MemberId(memberId)); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 빈 문자열 또는 null") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void emptyOrNullMemberId(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 4자 미만") + @ValueSource(strings = {"a", "ab", "abc"}) + void memberIdTooShort(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @Test + @DisplayName("아이디 형식 오류 - 20자 초과") + void memberIdTooLong() { + // given + String longMemberId = "a".repeat(21); + + // when & then + assertThatThrownBy(() -> new MemberId(longMemberId)) + .isInstanceOf(MemberValidationException.class); + } + + @Test + @DisplayName("아이디 길이 경계값 - 4자 성공") + void memberIdExactly4Chars() { + // when & then + assertThatNoException() + .isThrownBy(() -> new MemberId("abcd")); + } + + @Test + @DisplayName("아이디 길이 경계값 - 20자 성공") + void memberIdExactly20Chars() { + // given + String exactMemberId = "a".repeat(20); + + // when & then + assertThatNoException() + .isThrownBy(() -> new MemberId(exactMemberId)); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 숫자로 시작") + @ValueSource(strings = {"1member", "123member", "1234"}) + void memberIdStartsWithDigit(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 특수문자 포함") + @ValueSource(strings = { + "member_name", + "member-name", + "member.name", + "member@name", + "member#name", + "member$name", + "member%name", + "member name", + "member!name", + "member+name" + }) + void memberIdWithSpecialChars(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 한글 포함") + @ValueSource(strings = {"member한글", "한글member", "유저이름"}) + void memberIdWithKorean(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 예약어 사용") + @ValueSource(strings = { + "admin", "ADMIN", "Admin", + "root", "system", "support", + "test", "guest", "anonymous" + }) + void memberIdWithReservedWord(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("예약어 포함하지만 다른 아이디 - 성공") + @ValueSource(strings = {"admin1", "testmember", "myguest", "root123"}) + void memberIdContainsReservedWordButDifferent(String memberId) { + // when & then + assertThatNoException() + .isThrownBy(() -> new MemberId(memberId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java similarity index 86% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/vo/NameTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java index 5abea52b4..3fa358832 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/NameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -41,7 +41,7 @@ void validEnglishName(String name) { void emptyOrNullName(String name) { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -50,7 +50,7 @@ void emptyOrNullName(String name) { void mixedName(String name) { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -59,7 +59,7 @@ void mixedName(String name) { void nameWithNumbers(String name) { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -68,7 +68,7 @@ void nameWithNumbers(String name) { void nameWithSpecialChars(String name) { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @ParameterizedTest @@ -77,7 +77,7 @@ void nameWithSpecialChars(String name) { void nameWithSpaces(String name) { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @@ -93,7 +93,7 @@ void koreanNameExactly4Chars() { void koreanNameExceeds4Chars() { // when & then assertThatThrownBy(() -> new Name("홍길동님이")) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } @Test @@ -115,7 +115,7 @@ void englishNameExceeds50Chars() { // when & then assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + .isInstanceOf(MemberValidationException.class); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java similarity index 73% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/vo/PasswordTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java index baa34acc2..438ea6541 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.domain.member.exception.MemberValidationException; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,7 +16,7 @@ public void testPasswordSuccess() { @DisplayName("대문자 없음") public void throwsExceptionWhenNoUppercase() { Assertions.assertThatThrownBy(() -> new Password("1q2w3e4r!")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("대문자를 포함해야 합니다"); } @@ -24,7 +24,7 @@ public void throwsExceptionWhenNoUppercase() { @DisplayName("소문자 없음") public void throwsExceptionWhenNoLowercase() { Assertions.assertThatThrownBy(() -> new Password("1Q2W3E4R!")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("소문자를 포함해야 합니다"); } @@ -32,7 +32,7 @@ public void throwsExceptionWhenNoLowercase() { @DisplayName("숫자 없음") public void throwsExceptionWhenNoDigit() { Assertions.assertThatThrownBy(() -> new Password("qQwweerr!")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("숫자를 포함해야 합니다"); } @@ -40,7 +40,7 @@ public void throwsExceptionWhenNoDigit() { @DisplayName("특수문자 없음") public void throwsExceptionWhenNoSpecialChar() { Assertions.assertThatThrownBy(() -> new Password("qQwweerrr1")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("특수문자를 포함해야 합니다"); } @@ -48,7 +48,7 @@ public void throwsExceptionWhenNoSpecialChar() { @DisplayName("길이 미달") public void throwsExceptionWhenTooShort() { Assertions.assertThatThrownBy(() -> new Password("1Q2w3e!")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("비밀번호는 8자 이상이어야 합니다"); } @@ -63,7 +63,7 @@ public void passwordExactly8Chars() { @DisplayName("길이 초과") public void throwsExceptionWhenTooLong() { Assertions.assertThatThrownBy(() -> new Password("1Q2w3e4r!12345678")) - .isInstanceOf(UserValidationException.class) + .isInstanceOf(MemberValidationException.class) .hasMessage("비밀번호는 16자 이하여야 합니다"); } @@ -73,4 +73,14 @@ public void passwordExactly16Chars() { Assertions.assertThatNoException() .isThrownBy(() -> new Password("1Q2w3e4r!1234567")); } + + @Test + @DisplayName("BCrypt $2y$ prefix를 인코딩 비밀번호로 인식한다") + public void recognizesBcrypt2yPrefixAsEncoded() { + // given + Password encoded = Password.ofEncoded("$2y$10$dummyEncodedPasswordForTest"); + + // when & then + Assertions.assertThat(encoded.isEncoded()).isTrue(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PhoneTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PhoneTest.java new file mode 100644 index 000000000..77dff501c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PhoneTest.java @@ -0,0 +1,82 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.exception.MemberValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +public class PhoneTest { + + @Nested + @DisplayName("전화번호 형식 검증") + class PhoneValidationTest { + + @ParameterizedTest + @DisplayName("유효한 전화번호 형식") + @ValueSource(strings = { + "010-1234-5678", + "010-0000-0000", + "010-9999-9999" + }) + void validPhone(String phone) { + // when & then + assertThatNoException() + .isThrownBy(() -> new Phone(phone)); + } + + @Test + @DisplayName("전화번호 형식 오류 - 하이픈 누락") + void phoneWithoutHyphen() { + // when & then + assertThatThrownBy(() -> new Phone("01012345678")) + .isInstanceOf(MemberValidationException.class) + .hasMessage("전화번호는 010-XXXX-XXXX 형식이어야 합니다"); + } + + @ParameterizedTest + @DisplayName("전화번호 형식 오류 - 잘못된 접두사") + @ValueSource(strings = { + "011-1234-5678", + "016-1234-5678", + "019-1234-5678", + "010-123-4567", + "010-1234-567" + }) + void invalidPhonePrefix(String phone) { + // when & then + assertThatThrownBy(() -> new Phone(phone)) + .isInstanceOf(MemberValidationException.class); + } + + @ParameterizedTest + @DisplayName("전화번호 형식 오류 - 빈 문자열 또는 null") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void emptyOrNullPhone(String phone) { + // when & then + assertThatThrownBy(() -> new Phone(phone)) + .isInstanceOf(MemberValidationException.class) + .hasMessage("전화번호는 필수 입력값입니다"); + } + + @ParameterizedTest + @DisplayName("전화번호 형식 오류 - 완전한 잘못된 형식") + @ValueSource(strings = { + "abc-def-ghij", + "010123456789", + "010-12-3456", + "010-12345-678", + "" + }) + void completelyInvalidFormat(String phone) { + // when & then + assertThatThrownBy(() -> new Phone(phone)) + .isInstanceOf(MemberValidationException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..623da84c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderItemTest { + + @Nested + @DisplayName("주문 항목 생성") + class Create { + + @Test + @DisplayName("유효한 값으로 OrderItem 생성 성공") + void createSuccess() { + OrderItem item = new OrderItem(1L, 2, "강아지 사료", 10000, "퍼피박스"); + + assertThat(item.productId()).isEqualTo(1L); + assertThat(item.quantity()).isEqualTo(2); + assertThat(item.snapshotProductName()).isEqualTo("강아지 사료"); + assertThat(item.snapshotPrice()).isEqualTo(10000); + assertThat(item.snapshotBrandName()).isEqualTo("퍼피박스"); + } + + @Test + @DisplayName("수량이 0이면 예외가 발생한다") + void zeroQuantityFails() { + assertThatThrownBy(() -> new OrderItem(1L, 0, "강아지 사료", 10000, "퍼피박스")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("수량이 음수이면 예외가 발생한다") + void negativeQuantityFails() { + assertThatThrownBy(() -> new OrderItem(1L, -1, "강아지 사료", 10000, "퍼피박스")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("상품명 스냅샷이 blank이면 예외가 발생한다") + void blankProductNameFails() { + assertThatThrownBy(() -> new OrderItem(1L, 1, " ", 10000, "퍼피박스")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("브랜드명 스냅샷이 blank이면 예외가 발생한다") + void blankBrandNameFails() { + assertThatThrownBy(() -> new OrderItem(1L, 1, "강아지 사료", 10000, " ")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("가격 스냅샷이 음수이면 예외가 발생한다") + void negativePriceFails() { + assertThatThrownBy(() -> new OrderItem(1L, 1, "강아지 사료", -1, "퍼피박스")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("총 금액 계산") + class TotalPrice { + + @Test + @DisplayName("수량 * 단가를 반환한다") + void calculateTotalPrice() { + OrderItem item = new OrderItem(1L, 3, "강아지 사료", 5000, "퍼피박스"); + + assertThat(item.totalPrice()).isEqualTo(15000); + } + } +} 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..3c1e399e3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + private static final OrderItem SAMPLE_ITEM = new OrderItem( + 1L, 2, "강아지 사료", 10000, "퍼피박스" + ); + + @Nested + @DisplayName("주문 생성") + class Create { + + @Test + @DisplayName("유효한 항목으로 주문 생성 시 ORDERED 상태로 생성된다") + void createOrderSuccess() { + Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + + assertThat(order.status()).isEqualTo(OrderStatus.ORDERED); + assertThat(order.userId()).isEqualTo(1L); + assertThat(order.items()).hasSize(1); + assertThat(order.totalAmount()).isEqualTo(20000); + } + + @Test + @DisplayName("userId가 null이면 예외가 발생한다") + void nullUserIdFails() { + assertThatThrownBy(() -> new Order(null, "ORDER-001", List.of(SAMPLE_ITEM))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("빈 items로 주문 생성 시 예외가 발생한다") + void emptyItemsFails() { + assertThatThrownBy(() -> new Order(1L, "ORDER-001", List.of())) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @Test + @DisplayName("ORDERED 상태 주문을 취소하면 CANCELLED 상태가 된다") + void cancelOrderedOrder() { + Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + + Order cancelled = order.cancel(); + + assertThat(cancelled.status()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("이미 취소된 주문을 재취소하면 409 예외가 발생한다") + void cancelAlreadyCancelledOrderFails() { + Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order cancelled = order.cancel(); + + assertThatThrownBy(cancelled::cancel) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + } + + @Nested + @DisplayName("주문 소유자 확인") + class Ownership { + + @Test + @DisplayName("주문한 userId와 일치하면 true를 반환한다") + void isOwnerReturnsTrue() { + Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + + assertThat(order.isOwner(1L)).isTrue(); + } + + @Test + @DisplayName("주문한 userId와 다르면 false를 반환한다") + void isOwnerReturnsFalse() { + Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + + assertThat(order.isOwner(2L)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..1e1370cf4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,107 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + @Test + @DisplayName("좋아요 감소 시 0 미만으로 내려가지 않는다") + void decreaseLikeCountNotBelowZero() { + Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + + Product decreased = product.decreaseLikeCount(); + + assertThat(decreased.likeCount()).isZero(); + } + + @Test + @DisplayName("가격이 음수면 예외가 발생한다") + void negativePriceFails() { + assertThatThrownBy(() -> new Product("사료", -1, 10, "desc", 1L, 1L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("카테고리 ID가 없으면 예외가 발생한다") + void categoryIdMissingFails() { + assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", null, 1L)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("브랜드 ID가 없으면 예외가 발생한다") + void brandIdMissingFails() { + assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", 1L, null)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Nested + @DisplayName("재고 차감") + class DecreaseStock { + + @Test + @DisplayName("정상 수량으로 재고 차감 시 재고가 줄어든다") + void decreaseStockSuccess() { + Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + + Product updated = product.decreaseStock(3); + + assertThat(updated.stock()).isEqualTo(7); + } + + @Test + @DisplayName("재고보다 많은 수량으로 차감 시 예외가 발생한다") + void decreaseStockBelowZeroFails() { + Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + + assertThatThrownBy(() -> product.decreaseStock(6)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("수량이 0이면 예외가 발생한다") + void zeroQuantityFails() { + Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + + assertThatThrownBy(() -> product.decreaseStock(0)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("재고 복원") + class IncreaseStock { + + @Test + @DisplayName("정상 수량으로 재고 복원 시 재고가 늘어난다") + void increaseStockSuccess() { + Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + + Product updated = product.increaseStock(3); + + assertThat(updated.stock()).isEqualTo(8); + } + + @Test + @DisplayName("수량이 0이면 예외가 발생한다") + void zeroQuantityFails() { + Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + + assertThatThrownBy(() -> product.increaseStock(0)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserAuthServiceTest.java deleted file mode 100644 index 7a6dacc0d..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserAuthServiceTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.command.AuthenticateCommand; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class UserAuthServiceTest { - - @Mock - private UserRepository userRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private UserAuthService userAuthService; - - private UserId userId; - private Name name; - private Email email; - private BirthDate birthDate; - - @BeforeEach - void setUp() { - userId = new UserId("testuser"); - name = new Name("홍길동"); - email = new Email("test@example.com"); - birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); - } - - @Nested - @DisplayName("인증 (내 정보 조회)") - class AuthenticateTest { - - @Test - @DisplayName("성공") - void authenticateSuccess() { - // given - User savedUser = new User(userId, Password.ofEncoded("encodedPassword"), name, email, birthDate); - when(userRepository.findByUserId(userId)).thenReturn(Optional.of(savedUser)); - when(passwordEncoder.matches("1Q2w3e4r!", "encodedPassword")).thenReturn(true); - - AuthenticateCommand command = AuthenticateCommand.builder() - .userId(userId) - .rawPassword("1Q2w3e4r!") - .build(); - - // when - User result = userAuthService.authenticate(command); - - // then - assertThat(result).isNotNull(); - assertThat(result.id()).isEqualTo(userId); - } - - @Test - @DisplayName("실패 - 존재하지 않는 사용자") - void authenticateFailUserNotFound() { - // given - when(userRepository.findByUserId(userId)).thenReturn(Optional.empty()); - - AuthenticateCommand command = AuthenticateCommand.builder() - .userId(userId) - .rawPassword("1Q2w3e4r!") - .build(); - - // when & then - assertThatThrownBy(() -> userAuthService.authenticate(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ex = (CoreException) e; - assertThat(ex.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - }); - } - - @Test - @DisplayName("실패 - 비밀번호 불일치") - void authenticateFailWrongPassword() { - // given - User savedUser = new User(userId, Password.ofEncoded("encodedPassword"), name, email, birthDate); - when(userRepository.findByUserId(userId)).thenReturn(Optional.of(savedUser)); - when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); - - AuthenticateCommand command = AuthenticateCommand.builder() - .userId(userId) - .rawPassword("wrongPassword") - .build(); - - // when & then - assertThatThrownBy(() -> userAuthService.authenticate(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ex = (CoreException) e; - assertThat(ex.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - }); - } - } -} 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 deleted file mode 100644 index 872a67c9b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.command.ChangePasswordCommand; -import com.loopers.application.user.command.RegisterCommand; -import com.loopers.domain.user.vo.BirthDate; -import com.loopers.domain.user.vo.Email; -import com.loopers.domain.user.vo.Name; -import com.loopers.domain.user.vo.Password; -import com.loopers.domain.user.vo.UserId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class UserServiceTest { - - @Mock - private UserRepository userRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private UserService userService; - - private UserId userId; - private Password password; - private Name name; - private Email email; - private BirthDate birthDate; - private User user; - - @BeforeEach - void setUp() { - userId = new UserId("testuser"); - password = new Password("1Q2w3e4r!"); - name = new Name("홍길동"); - email = new Email("test@example.com"); - birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); - user = new User(userId, password, name, email, birthDate); - } - - @Nested - @DisplayName("회원가입") - class RegisterTest { - - @Test - @DisplayName("성공") - void registerSuccess() { - // given - RegisterCommand command = RegisterCommand.builder() - .userId("testuser") - .rawPassword("1Q2w3e4r!") - .name("홍길동") - .email("test@example.com") - .birthDate("19990115") - .build(); - - when(userRepository.existsByUserId(any(UserId.class))).thenReturn(false); - when(passwordEncoder.encode("1Q2w3e4r!")).thenReturn("$2a$10$encodedPassword"); - when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // when - User result = userService.register(command); - - // then - assertThat(result).isNotNull(); - assertThat(result.id().value()).isEqualTo("testuser"); - assertThat(result.password().value()).isEqualTo("$2a$10$encodedPassword"); - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("실패 - 로그인 ID 중복") - void registerFailDuplicateUserId() { - // given - RegisterCommand command = RegisterCommand.builder() - .userId("testuser") - .rawPassword("1Q2w3e4r!") - .name("홍길동") - .email("test@example.com") - .birthDate("19990115") - .build(); - - when(userRepository.existsByUserId(any(UserId.class))).thenReturn(true); - - // when & then - assertThatThrownBy(() -> userService.register(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ex = (CoreException) e; - assertThat(ex.getErrorType()).isEqualTo(ErrorType.CONFLICT); - }); - - verify(userRepository, never()).save(any(User.class)); - } - } - - @Nested - @DisplayName("비밀번호 수정") - class ChangePasswordTest { - - @Test - @DisplayName("성공") - void changePasswordSuccess() { - // given - User savedUser = new User(userId, Password.ofEncoded("encodedPassword"), name, email, birthDate); - when(userRepository.findByUserId(userId)).thenReturn(Optional.of(savedUser)); - when(passwordEncoder.matches("New1234!@", "encodedPassword")).thenReturn(false); - when(passwordEncoder.encode("New1234!@")).thenReturn("$2a$10$newEncodedPassword"); - when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - ChangePasswordCommand command = ChangePasswordCommand.builder() - .userId(userId) - .newRawPassword("New1234!@") - .birthDate(birthDate) - .build(); - - // when & then - assertThatNoException() - .isThrownBy(() -> userService.changePassword(command)); - - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("실패 - 존재하지 않는 사용자") - void changePasswordFailUserNotFound() { - // given - when(userRepository.findByUserId(userId)).thenReturn(Optional.empty()); - - ChangePasswordCommand command = ChangePasswordCommand.builder() - .userId(userId) - .newRawPassword("New1234!@") - .birthDate(birthDate) - .build(); - - // when & then - assertThatThrownBy(() -> userService.changePassword(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ex = (CoreException) e; - assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - }); - - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("실패 - 새 비밀번호가 기존과 동일") - void changePasswordFailSamePassword() { - // given - User savedUser = new User(userId, Password.ofEncoded("encodedPassword"), name, email, birthDate); - when(userRepository.findByUserId(userId)).thenReturn(Optional.of(savedUser)); - when(passwordEncoder.matches("1Q2w3e4r!", "encodedPassword")).thenReturn(true); - - ChangePasswordCommand command = ChangePasswordCommand.builder() - .userId(userId) - .newRawPassword("1Q2w3e4r!") - .birthDate(birthDate) - .build(); - - // when & then - assertThatThrownBy(() -> userService.changePassword(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ex = (CoreException) e; - assertThat(ex.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - }); - - verify(userRepository, never()).save(any(User.class)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/UserIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/UserIdTest.java deleted file mode 100644 index 20fce4e5f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/vo/UserIdTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.loopers.domain.user.vo; - -import com.loopers.domain.user.exception.UserValidationException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.*; - -public class UserIdTest { - - @Nested - @DisplayName("아이디 형식 검증") - class UserIdValidationTest { - - @ParameterizedTest - @DisplayName("유효한 아이디 형식") - @ValueSource(strings = { - "user", - "user1", - "user123", - "User123", - "a1234", - "abcd", - "user1234567890abcd" - }) - void validUserId(String userId) { - // when & then - assertThatNoException() - .isThrownBy(() -> new UserId(userId)); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 빈 문자열 또는 null") - @NullAndEmptySource - @ValueSource(strings = {" "}) - void emptyOrNullUserId(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 4자 미만") - @ValueSource(strings = {"a", "ab", "abc"}) - void userIdTooShort(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @Test - @DisplayName("아이디 형식 오류 - 20자 초과") - void userIdTooLong() { - // given - String longUserId = "a".repeat(21); - - // when & then - assertThatThrownBy(() -> new UserId(longUserId)) - .isInstanceOf(UserValidationException.class); - } - - @Test - @DisplayName("아이디 길이 경계값 - 4자 성공") - void userIdExactly4Chars() { - // when & then - assertThatNoException() - .isThrownBy(() -> new UserId("abcd")); - } - - @Test - @DisplayName("아이디 길이 경계값 - 20자 성공") - void userIdExactly20Chars() { - // given - String exactUserId = "a".repeat(20); - - // when & then - assertThatNoException() - .isThrownBy(() -> new UserId(exactUserId)); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 숫자로 시작") - @ValueSource(strings = {"1user", "123user", "1234"}) - void userIdStartsWithDigit(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 특수문자 포함") - @ValueSource(strings = { - "user_name", - "user-name", - "user.name", - "user@name", - "user#name", - "user$name", - "user%name", - "user name", - "user!name", - "user+name" - }) - void userIdWithSpecialChars(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 한글 포함") - @ValueSource(strings = {"user한글", "한글user", "유저이름"}) - void userIdWithKorean(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @ParameterizedTest - @DisplayName("아이디 형식 오류 - 예약어 사용") - @ValueSource(strings = { - "admin", "ADMIN", "Admin", - "root", "system", "support", - "test", "guest", "anonymous" - }) - void userIdWithReservedWord(String userId) { - // when & then - assertThatThrownBy(() -> new UserId(userId)) - .isInstanceOf(UserValidationException.class); - } - - @ParameterizedTest - @DisplayName("예약어 포함하지만 다른 아이디 - 성공") - @ValueSource(strings = {"admin1", "testuser", "myguest", "root123"}) - void userIdContainsReservedWordButDifferent(String userId) { - // when & then - assertThatNoException() - .isThrownBy(() -> new UserId(userId)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java new file mode 100644 index 000000000..5e2121cdd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java @@ -0,0 +1,146 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.category.CategoryDto; +import com.loopers.testcontainers.MySqlTestContainersConfig; +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.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class AdminCategoryApiE2ETest { + + private static final String ENDPOINT_CATEGORIES = "/api-admin/v1/categories"; + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_ADMIN = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final CategoryRepository categoryRepository; + + @Autowired + AdminCategoryApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + CategoryRepository categoryRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.categoryRepository = categoryRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api-admin/v1/categories") + class List { + + @Test + @DisplayName("LDAP 헤더가 있으면 카테고리 목록을 페이지로 조회한다") + void listCategories_withLdapHeader_returnsPagedList() { + createCategory("푸드"); + createCategory("장난감"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CATEGORIES + "?page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); + assertThat(response.getBody().data().page()).isEqualTo(0); + assertThat(response.getBody().data().size()).isEqualTo(20); + assertThat(response.getBody().data().totalElements()).isEqualTo(2); + assertThat(response.getBody().data().items()).hasSize(2); + } + + @Test + @DisplayName("LDAP 헤더가 없으면 401을 반환한다") + void listCategories_withoutLdapHeader_returnsUnauthorized() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CATEGORIES + "?page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + @DisplayName("GET /api-admin/v1/categories/{categoryId}") + class Detail { + + @Test + @DisplayName("존재하는 카테고리를 상세 조회한다") + void getCategory_whenExists_returnsOk() { + Long categoryId = createCategory("리빙"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CATEGORIES + "/" + categoryId, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); + assertThat(response.getBody().data().id()).isEqualTo(categoryId); + assertThat(response.getBody().data().name()).isEqualTo("리빙"); + } + + @Test + @DisplayName("존재하지 않는 카테고리 ID 조회 시 400을 반환한다") + void getCategory_whenNotExists_returnsBadRequest() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CATEGORIES + "/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LDAP, LDAP_ADMIN); + return headers; + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java similarity index 59% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java index 5c4811aac..2a830193b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.api.member; import com.loopers.interfaces.api.ApiResponse; import com.loopers.testcontainers.MySqlTestContainersConfig; @@ -25,11 +25,12 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Import(MySqlTestContainersConfig.class) @ActiveProfiles("test") -class UserApiE2ETest { +class MemberApiE2ETest { - private static final String ENDPOINT_USERS = "/api/v1/users"; - private static final String ENDPOINT_ME = "/api/v1/users/me"; - private static final String ENDPOINT_PASSWORD = "/api/v1/users/me/password"; + private static final String ENDPOINT_USERS = "/api/v1/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_PASSWORD = "/api/v1/members/me/password"; + private static final String ENDPOINT_DUPLICATE = "/api/v1/members/duplicate"; private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; @@ -38,7 +39,7 @@ class UserApiE2ETest { private final DatabaseCleanUp databaseCleanUp; @Autowired - public UserApiE2ETest( + public MemberApiE2ETest( TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp ) { @@ -51,7 +52,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("POST /api/v1/users - 회원가입") + @DisplayName("POST /api/v1/members - 회원가입") @Nested class Register { @@ -59,12 +60,13 @@ class Register { @Test void returnsCreated_whenValidRequest() { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act @@ -81,14 +83,15 @@ void returnsCreated_whenValidRequest() { @DisplayName("이미 존재하는 아이디로 가입하면 409 Conflict를 반환한다") @Test - void returnsConflict_whenDuplicateUserId() { + void returnsConflict_whenDuplicateMemberId() { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); testRestTemplate.exchange( ENDPOINT_USERS, @@ -97,12 +100,13 @@ void returnsConflict_whenDuplicateUserId() { new ParameterizedTypeReference>() {} ); - UserDto.RegisterRequest duplicateRequest = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest duplicateRequest = new MemberDto.RegisterRequest( + "testmember1", "Password2!", "김철수", "19950505", - "another@example.com" + "another@example.com", + "010-5678-1234" ); // act @@ -116,26 +120,99 @@ void returnsConflict_whenDuplicateUserId() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } + + @DisplayName("전화번호 형식이 잘못되면 400 Bad Request를 반환한다") + @Test + void returnsBadRequest_whenPhoneInvalidFormat() { + // arrange + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "010-123-4567" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_USERS, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } - @DisplayName("GET /api/v1/users/me - 내 정보 조회") + @DisplayName("GET /api/v1/members/duplicate - 로그인 ID 중복 검사") + @Nested + class DuplicateCheck { + + @DisplayName("사용 가능한 아이디면 available=true를 반환한다") + @Test + void returnsAvailable_whenLoginIdIsAvailable() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_DUPLICATE + "?loginId=newmember123", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().available()).isTrue(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("newmember123") + ); + } + + @DisplayName("이미 사용 중인 아이디면 available=false를 반환한다") + @Test + void returnsUnavailable_whenLoginIdIsAlreadyUsed() { + // arrange + String loginId = "existingmember"; + registerMember(loginId, "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_DUPLICATE + "?loginId=" + loginId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().available()).isFalse(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId) + ); + } + } + + @DisplayName("GET /api/v1/members/me - 내 정보 조회") @Nested class GetMe { @DisplayName("인증된 사용자가 조회하면 마스킹된 이름과 함께 정보를 반환한다") @Test - void returnsUserInfo_whenAuthenticated() { + void returnsMemberInfo_whenAuthenticated() { // arrange - String loginId = "testuser1"; + String loginId = "testmember1"; String password = "Password1!"; - registerUser(loginId, password, "홍길동", "19900101", "test@example.com"); + String phone = "010-1234-5678"; + registerMember(loginId, password, "홍길동", "19900101", "test@example.com", phone); HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, password); // act - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), @@ -145,9 +222,10 @@ void returnsUserInfo_whenAuthenticated() { // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testmember1"), () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), - () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(response.getBody().data().phone()).isEqualTo(phone) ); } @@ -155,7 +233,7 @@ void returnsUserInfo_whenAuthenticated() { @Test void returnsUnauthorized_whenNoCredentials() { // act - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(null), @@ -170,15 +248,15 @@ void returnsUnauthorized_whenNoCredentials() { @Test void returnsUnauthorized_whenWrongPassword() { // arrange - String loginId = "testuser1"; - registerUser(loginId, "Password1!", "홍길동", "19900101", "test@example.com"); + String loginId = "testmember1"; + registerMember(loginId, "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, "WrongPass1!"); // act - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), @@ -190,7 +268,7 @@ void returnsUnauthorized_whenWrongPassword() { } } - @DisplayName("PATCH /api/v1/users/me/password - 비밀번호 변경") + @DisplayName("PATCH /api/v1/members/me/password - 비밀번호 변경") @Nested class ChangePassword { @@ -198,16 +276,15 @@ class ChangePassword { @Test void returnsOk_whenValidRequest() { // arrange - String loginId = "testuser1"; + String loginId = "testmember1"; String currentPassword = "Password1!"; - registerUser(loginId, currentPassword, "홍길동", "19900101", "test@example.com"); + registerMember(loginId, currentPassword, "홍길동", "19900101", "test@example.com", "010-1234-5678"); HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, currentPassword); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - currentPassword, + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( "NewPassword1!" ); @@ -223,48 +300,19 @@ void returnsOk_whenValidRequest() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } - @DisplayName("현재 비밀번호가 틀리면 401 Unauthorized를 반환한다") - @Test - void returnsUnauthorized_whenCurrentPasswordWrong() { - // arrange - String loginId = "testuser1"; - registerUser(loginId, "Password1!", "홍길동", "19900101", "test@example.com"); - - HttpHeaders headers = new HttpHeaders(); - headers.set(HEADER_LOGIN_ID, loginId); - headers.set(HEADER_LOGIN_PW, "Password1!"); - - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "WrongPassword1!", - "NewPassword1!" - ); - - // act - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_PASSWORD, - HttpMethod.PATCH, - new HttpEntity<>(request, headers), - new ParameterizedTypeReference<>() {} - ); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - @DisplayName("새 비밀번호가 현재 비밀번호와 같으면 400 Bad Request를 반환한다") @Test void returnsBadRequest_whenSamePassword() { // arrange - String loginId = "testuser1"; + String loginId = "testmember1"; String currentPassword = "Password1!"; - registerUser(loginId, currentPassword, "홍길동", "19900101", "test@example.com"); + registerMember(loginId, currentPassword, "홍길동", "19900101", "test@example.com", "010-1234-5678"); HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, currentPassword); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - currentPassword, + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( currentPassword ); @@ -281,8 +329,8 @@ void returnsBadRequest_whenSamePassword() { } } - private void registerUser(String loginId, String password, String name, String birthDate, String email) { - UserDto.RegisterRequest request = new UserDto.RegisterRequest(loginId, password, name, birthDate, email); + private void registerMember(String loginId, String password, String name, String birthDate, String email, String phone) { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest(loginId, password, name, birthDate, email, phone); testRestTemplate.exchange( ENDPOINT_USERS, HttpMethod.POST, diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java similarity index 56% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java index 52d507e4a..1bcb6066a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.api.member; import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.testcontainers.MySqlTestContainersConfig; @@ -25,7 +25,7 @@ @AutoConfigureMockMvc @Import(MySqlTestContainersConfig.class) @ActiveProfiles("test") -class UserControllerTest { +class MemberControllerTest { @Autowired private MockMvc mockMvc; @@ -42,23 +42,24 @@ void tearDown() { } @Nested - @DisplayName("POST /api/v1/users - 회원가입") + @DisplayName("POST /api/v1/members - 회원가입") class RegisterTest { @Test @DisplayName("유효한 요청이면 201 Created를 반환한다") void returnsCreated_whenValidRequest() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) @@ -69,16 +70,17 @@ void returnsCreated_whenValidRequest() throws Exception { @DisplayName("로그인 ID가 없으면 400 Bad Request를 반환한다") void returnsBadRequest_whenLoginIdIsBlank() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( "", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -88,16 +90,17 @@ void returnsBadRequest_whenLoginIdIsBlank() throws Exception { @DisplayName("로그인 ID가 4자 미만이면 400 Bad Request를 반환한다") void returnsBadRequest_whenLoginIdTooShort() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( "abc", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -107,16 +110,17 @@ void returnsBadRequest_whenLoginIdTooShort() throws Exception { @DisplayName("로그인 ID에 특수문자가 있으면 400 Bad Request를 반환한다") void returnsBadRequest_whenLoginIdHasSpecialChars() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "test@user", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "test@member", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -126,16 +130,17 @@ void returnsBadRequest_whenLoginIdHasSpecialChars() throws Exception { @DisplayName("비밀번호가 8자 미만이면 400 Bad Request를 반환한다") void returnsBadRequest_whenPasswordTooShort() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Pass1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -145,16 +150,17 @@ void returnsBadRequest_whenPasswordTooShort() throws Exception { @DisplayName("생년월일 형식이 잘못되면 400 Bad Request를 반환한다") void returnsBadRequest_whenBirthDateInvalidFormat() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "1990-01-01", - "test@example.com" + "test@example.com", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -164,16 +170,57 @@ void returnsBadRequest_whenBirthDateInvalidFormat() throws Exception { @DisplayName("이메일 형식이 잘못되면 400 Bad Request를 반환한다") void returnsBadRequest_whenEmailInvalidFormat() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "19900101", - "invalid-email" + "invalid-email", + "010-1234-5678" ); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("전화번호 형식이 잘못되면 400 Bad Request를 반환한다") + void returnsBadRequest_whenPhoneInvalidFormat() throws Exception { + // arrange + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "010-123-4567" + ); + + // act & assert + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("전화번호가 누락되면 400 Bad Request를 반환한다") + void returnsBadRequest_whenPhoneIsBlank() throws Exception { + // arrange + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "" + ); + + // act & assert + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -181,21 +228,22 @@ void returnsBadRequest_whenEmailInvalidFormat() throws Exception { @Test @DisplayName("이미 존재하는 아이디로 가입하면 409 Conflict를 반환한다") - void returnsConflict_whenDuplicateUserId() throws Exception { + void returnsConflict_whenDuplicateMemberId() throws Exception { // arrange - UserDto.RegisterRequest request = new UserDto.RegisterRequest( - "testuser1", + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", "Password1!", "홍길동", "19900101", - "test@example.com" + "test@example.com", + "010-1234-5678" ); - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); // act & assert - mockMvc.perform(post("/api/v1/users") + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isConflict()); @@ -203,30 +251,81 @@ void returnsConflict_whenDuplicateUserId() throws Exception { } @Nested - @DisplayName("GET /api/v1/users/me - 내 정보 조회") + @DisplayName("GET /api/v1/members/duplicate - 로그인 ID 중복 검사") + class DuplicateCheckTest { + + @Test + @DisplayName("사용 가능한 아이디면 available=true를 반환한다") + void returnsAvailable_whenLoginIdIsAvailable() throws Exception { + // act & assert + mockMvc.perform(get("/api/v1/members/duplicate") + .param("loginId", "newmember123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.available").value(true)) + .andExpect(jsonPath("$.data.loginId").value("newmember123")); + } + + @Test + @DisplayName("이미 사용 중인 아이디면 available=false를 반환한다") + void returnsUnavailable_whenLoginIdIsAlreadyUsed() throws Exception { + // arrange + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "existingmember", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "010-1234-5678" + ); + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // act & assert + mockMvc.perform(get("/api/v1/members/duplicate") + .param("loginId", "existingmember")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.available").value(false)) + .andExpect(jsonPath("$.data.loginId").value("existingmember")); + } + + @Test + @DisplayName("loginId 파라미터가 없으면 400 Bad Request를 반환한다") + void returnsBadRequest_whenLoginIdParamIsMissing() throws Exception { + // act & assert + mockMvc.perform(get("/api/v1/members/duplicate")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/v1/members/me - 내 정보 조회") class GetMeTest { @Test @DisplayName("인증된 사용자면 200 OK와 사용자 정보를 반환한다") void returnsOk_whenAuthenticated() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); // act & assert - mockMvc.perform(get("/api/v1/users/me") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(get("/api/v1/members/me") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "Password1!")) .andExpect(status().isOk()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.loginId").value("testuser1")) - .andExpect(jsonPath("$.data.name").value("홍길*")); + .andExpect(jsonPath("$.data.loginId").value("testmember1")) + .andExpect(jsonPath("$.data.name").value("홍길*")) + .andExpect(jsonPath("$.data.phone").value("010-1234-5678")); } @Test @DisplayName("인증 정보가 없으면 401 Unauthorized를 반환한다") void returnsUnauthorized_whenNoCredentials() throws Exception { // act & assert - mockMvc.perform(get("/api/v1/users/me")) + mockMvc.perform(get("/api/v1/members/me")) .andExpect(status().isUnauthorized()); } @@ -234,34 +333,33 @@ void returnsUnauthorized_whenNoCredentials() throws Exception { @DisplayName("잘못된 비밀번호로 조회하면 401 Unauthorized를 반환한다") void returnsUnauthorized_whenWrongPassword() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); // act & assert - mockMvc.perform(get("/api/v1/users/me") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(get("/api/v1/members/me") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "WrongPass1!")) .andExpect(status().isUnauthorized()); } } @Nested - @DisplayName("PATCH /api/v1/users/me/password - 비밀번호 변경") + @DisplayName("PATCH /api/v1/members/me/password - 비밀번호 변경") class ChangePasswordTest { @Test @DisplayName("유효한 요청이면 200 OK를 반환한다") void returnsOk_whenValidRequest() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "Password1!", + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( "NewPassword1!" ); // act & assert - mockMvc.perform(patch("/api/v1/users/me/password") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(patch("/api/v1/members/me/password") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "Password1!") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -273,16 +371,15 @@ void returnsOk_whenValidRequest() throws Exception { @DisplayName("새 비밀번호가 없으면 400 Bad Request를 반환한다") void returnsBadRequest_whenNewPasswordIsBlank() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "Password1!", + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( "" ); // act & assert - mockMvc.perform(patch("/api/v1/users/me/password") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(patch("/api/v1/members/me/password") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "Password1!") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -293,56 +390,34 @@ void returnsBadRequest_whenNewPasswordIsBlank() throws Exception { @DisplayName("새 비밀번호가 8자 미만이면 400 Bad Request를 반환한다") void returnsBadRequest_whenNewPasswordTooShort() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "Password1!", + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( "Short1!" ); // act & assert - mockMvc.perform(patch("/api/v1/users/me/password") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(patch("/api/v1/members/me/password") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "Password1!") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } - @Test - @DisplayName("현재 비밀번호가 틀리면 401 Unauthorized를 반환한다") - void returnsUnauthorized_whenCurrentPasswordWrong() throws Exception { - // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); - - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "WrongPassword1!", - "NewPassword1!" - ); - - // act & assert - mockMvc.perform(patch("/api/v1/users/me/password") - .header("X-Loopers-LoginId", "testuser1") - .header("X-Loopers-LoginPw", "Password1!") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()); - } - @Test @DisplayName("새 비밀번호가 현재 비밀번호와 같으면 400 Bad Request를 반환한다") void returnsBadRequest_whenSamePassword() throws Exception { // arrange - registerUser("testuser1", "Password1!", "홍길동", "19900101", "test@example.com"); + registerMember("testmember1", "Password1!", "홍길동", "19900101", "test@example.com", "010-1234-5678"); - UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest( - "Password1!", + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest( "Password1!" ); // act & assert - mockMvc.perform(patch("/api/v1/users/me/password") - .header("X-Loopers-LoginId", "testuser1") + mockMvc.perform(patch("/api/v1/members/me/password") + .header("X-Loopers-LoginId", "testmember1") .header("X-Loopers-LoginPw", "Password1!") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -350,9 +425,9 @@ void returnsBadRequest_whenSamePassword() throws Exception { } } - private void registerUser(String loginId, String password, String name, String birthDate, String email) throws Exception { - UserDto.RegisterRequest request = new UserDto.RegisterRequest(loginId, password, name, birthDate, email); - mockMvc.perform(post("/api/v1/users") + private void registerMember(String loginId, String password, String name, String birthDate, String email, String phone) throws Exception { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest(loginId, password, name, birthDate, email, phone); + mockMvc.perform(post("/api/v1/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberDtoToStringTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberDtoToStringTest.java new file mode 100644 index 000000000..f034dbdd6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberDtoToStringTest.java @@ -0,0 +1,56 @@ +package com.loopers.interfaces.api.member; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemberDtoToStringTest { + + @Test + @DisplayName("회원가입 요청 toString은 비밀번호를 마스킹한다") + void registerRequestToStringMasksPassword() { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "010-1234-5678" + ); + + String result = request.toString(); + + assertThat(result).contains("password=***"); + assertThat(result).doesNotContain("Password1!"); + } + + @Test + @DisplayName("회원가입 요청 toString은 전화번호를 마스킹한다") + void registerRequestToStringMasksPhone() { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + "testmember1", + "Password1!", + "홍길동", + "19900101", + "test@example.com", + "010-1234-5678" + ); + + String result = request.toString(); + + assertThat(result).contains("phone=010-****-5678"); + assertThat(result).doesNotContain("010-1234-5678"); + } + + @Test + @DisplayName("비밀번호 변경 요청 toString은 새 비밀번호를 마스킹한다") + void changePasswordRequestToStringMasksPassword() { + MemberDto.ChangePasswordRequest request = new MemberDto.ChangePasswordRequest("NewPassword1!"); + + String result = request.toString(); + + assertThat(result).contains("newPassword=***"); + assertThat(result).doesNotContain("NewPassword1!"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java new file mode 100644 index 000000000..52aefa829 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -0,0 +1,272 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductDto; +import com.loopers.interfaces.api.member.MemberDto; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class OrderApiE2ETest { + + private static final String ENDPOINT_ORDERS = "/api/v1/orders"; + private static final String ENDPOINT_BRANDS = "/api-admin/v1/brands"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String TEST_LOGIN_ID = "orderuser1"; + private static final String TEST_PASSWORD = "Test1234!@"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final CategoryRepository categoryRepository; + private Long brandId; + private Long categoryId; + + @Autowired + public OrderApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp, CategoryRepository categoryRepository) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.categoryRepository = categoryRepository; + } + + @BeforeEach + void setUp() { + // 테스트 유저 생성 + MemberDto.RegisterRequest registerRequest = new MemberDto.RegisterRequest( + TEST_LOGIN_ID, TEST_PASSWORD, "주문자", "19900101", "order@test.com", "010-1234-5678" + ); + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + brandId = createBrand("ORDER_TEST_BRAND"); + categoryId = createCategory("ORDER_TEST_CATEGORY"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, TEST_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, TEST_PASSWORD); + return headers; + } + + private Long createProduct(String name, int price, int stock) { + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + name, price, stock, "설명", categoryId, brandId + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); + return response.getBody().data().id(); + } + + private Long createBrand(String name) { + var request = new com.loopers.interfaces.api.brand.BrandDto.CreateBrandRequest( + name, + "테스트 브랜드", + "https://example.com/logo.png" + ); + + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_BRANDS, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); + + return response.getBody().data().id(); + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } + + @Nested + @DisplayName("주문 CRUD 시나리오") + class OrderCrudScenario { + + @Test + @DisplayName("주문 생성 → 상세 조회 → 취소 → 재취소 실패 시나리오") + void fullOrderFlow() { + // 상품 생성 + Long productId = createProduct("강아지 사료", 10000, 50); + + // 주문 생성 + OrderDto.CreateOrderRequest createRequest = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(productId, 2)) + ); + + ResponseEntity> created = testRestTemplate.exchange( + ENDPOINT_ORDERS, + HttpMethod.POST, + new HttpEntity<>(createRequest, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + Long orderId = created.getBody().data().id(); + assertThat(created.getBody().data().status()).isEqualTo("ORDERED"); + assertThat(created.getBody().data().totalAmount()).isEqualTo(20000); + + // 주문 상세 조회 + ResponseEntity> detail = testRestTemplate.exchange( + ENDPOINT_ORDERS + "/" + orderId, + HttpMethod.GET, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(detail.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(detail.getBody().data().items()).hasSize(1); + assertThat(detail.getBody().data().items().get(0).snapshotProductName()).isEqualTo("강아지 사료"); + + // 주문 취소 + ResponseEntity> cancelled = testRestTemplate.exchange( + ENDPOINT_ORDERS + "/" + orderId + "/cancel", + HttpMethod.PATCH, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(cancelled.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(cancelled.getBody().data().status()).isEqualTo("CANCELLED"); + + // 재취소 시 409 + ResponseEntity> reCancelled = testRestTemplate.exchange( + ENDPOINT_ORDERS + "/" + orderId + "/cancel", + HttpMethod.PATCH, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(reCancelled.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + @DisplayName("재고 부족 시 주문이 실패하고 재고가 차감되지 않는다") + void insufficientStockFails() { + Long productId = createProduct("한정판 사료", 50000, 2); + + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(productId, 5)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("주문 목록 조회 - 기간 내 주문만 반환된다") + void listOrdersWithDateFilter() { + Long productId = createProduct("사료", 5000, 100); + + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(productId, 1)) + ); + + testRestTemplate.exchange( + ENDPOINT_ORDERS, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> listResponse = testRestTemplate.exchange( + ENDPOINT_ORDERS + "?startAt=20260101&endAt=20261231&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listResponse.getBody().data().totalElements()).isGreaterThanOrEqualTo(1); + } + + @Test + @DisplayName("취소 후 재고가 복원된다") + void stockRestoredAfterCancel() { + Long productId = createProduct("귀한 사료", 20000, 3); + + // 3개 주문 (재고 0이 됨) + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(productId, 3)) + ); + + ResponseEntity> created = testRestTemplate.exchange( + ENDPOINT_ORDERS, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + Long orderId = created.getBody().data().id(); + + // 취소 (재고 복원) + testRestTemplate.exchange( + ENDPOINT_ORDERS + "/" + orderId + "/cancel", + HttpMethod.PATCH, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // 다시 주문 가능 (재고 복원 확인) + ResponseEntity> reOrder = testRestTemplate.exchange( + ENDPOINT_ORDERS, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(reOrder.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java new file mode 100644 index 000000000..c9bb97084 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java @@ -0,0 +1,172 @@ +package com.loopers.interfaces.api.order; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class OrderControllerTest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String TEST_LOGIN_ID = "testuser1"; + private static final String TEST_PASSWORD = "Test1234!@"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() throws Exception { + // 테스트 유저 등록 + var registerRequest = new com.loopers.interfaces.api.member.MemberDto.RegisterRequest( + TEST_LOGIN_ID, TEST_PASSWORD, "테스터", "19900101", "test@example.com", "010-1234-5678" + ); + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/orders") + class CreateOrder { + + @Test + @DisplayName("인증 없이 주문하면 401을 반환한다") + void createOrderWithoutAuthFails() throws Exception { + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(1L, 1)) + ); + + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("빈 items로 주문하면 400을 반환한다") + void createOrderWithEmptyItemsFails() throws Exception { + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest(List.of()); + + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("존재하지 않는 상품 주문 시 404를 반환한다") + void createOrderWithNonExistentProductFails() throws Exception { + OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( + List.of(new OrderDto.OrderItemRequest(99999L, 1)) + ); + + mockMvc.perform(post("/api/v1/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("PATCH /api/v1/orders/{orderId}/cancel") + class CancelOrder { + + @Test + @DisplayName("존재하지 않는 주문 취소 시 404를 반환한다") + void cancelNonExistentOrderFails() throws Exception { + mockMvc.perform(patch("/api/v1/orders/99999/cancel") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("인증 없이 취소하면 401을 반환한다") + void cancelWithoutAuthFails() throws Exception { + mockMvc.perform(patch("/api/v1/orders/1/cancel")) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /api/v1/orders") + class ListOrders { + + @Test + @DisplayName("startAt/endAt 누락 시 400을 반환한다") + void listOrdersWithoutDateParamsFails() throws Exception { + mockMvc.perform(get("/api/v1/orders") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("유효한 날짜로 주문 목록 조회 성공") + void listOrdersSuccess() throws Exception { + mockMvc.perform(get("/api/v1/orders") + .param("startAt", "20260101") + .param("endAt", "20261231") + .param("page", "0") + .param("size", "20") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.items").isArray()); + } + } + + @Nested + @DisplayName("GET /api/v1/orders/{orderId}") + class GetOrderDetail { + + @Test + @DisplayName("존재하지 않는 주문 조회 시 404를 반환한다") + void getNonExistentOrderFails() throws Exception { + mockMvc.perform(get("/api/v1/orders/99999") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java new file mode 100644 index 000000000..3a66b8078 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java @@ -0,0 +1,431 @@ +package com.loopers.interfaces.api.product; + +import com.fasterxml.jackson.databind.JsonNode; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberDto; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Like API E2E Tests") +class LikeApiE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + private static final String ENDPOINT_LIKES = "/likes"; + private static final String ENDPOINT_ME_LIKES = "/api/v1/me/likes"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final ProductJpaRepository productJpaRepository; + + private Long brandId; + private Long categoryId; + + @Autowired + public LikeApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + ProductJpaRepository productJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.productJpaRepository = productJpaRepository; + } + + @BeforeEach + void setUp() { + brandId = createBrand("LIKE_TEST_BRAND"); + categoryId = createCategory("LIKE_TEST_CATEGORY"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/products/{productId}/likes") + class Register { + + @Test + @DisplayName("인증된 사용자가 활성 상품에 좋아요를 누르면 201을 반환한다") + void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() { + registerMember("likeApiMember", "Password1!", "홍길동", "19900101", "api-like@example.com", "010-1234-5678"); + Long productId = createProduct("좋아요 상품", 10_000, 50); + + HttpHeaders headers = headers("likeApiMember", "Password1!"); + int beforeLikeCount = getProductLikeCount(productId); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(getProductLikeCount(productId)).isEqualTo(beforeLikeCount + 1); + } + + @Test + @DisplayName("이미 좋아요한 상품을 다시 누르면 409을 반환한다") + void registerLike_whenAlreadyLikedProduct_returnsConflict() { + registerMember("likeApiConfMem", "Password1!", "홍길동", "19900101", "api-like-conflict@example.com", "010-2345-6789"); + Long productId = createProduct("좋아요 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiConfMem", "Password1!"); + + testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + ResponseEntity> secondResponse = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + @DisplayName("삭제된 상품에 대해 좋아요 요청을 보내면 400을 반환한다") + void registerLike_whenDeletedProduct_returnsBadRequest() { + registerMember("likeApiDelMem", "Password1!", "홍길동", "19900101", "api-like-deleted@example.com", "010-3456-7890"); + Long productId = createProduct("삭제될 상품", 10_000, 50); + deleteProductAsAdmin(productId); + + HttpHeaders headers = headers("likeApiDelMem", "Password1!"); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요를 누르면 404를 반환한다") + void registerLike_whenProductNotFound_returnsNotFound() { + registerMember("likeApiMissMem", "Password1!", "홍길동", "19900101", "api-like-missing@example.com", "010-4567-8901"); + HttpHeaders headers = headers("likeApiMissMem", "Password1!"); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(0L), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("인증 정보가 없으면 401을 반환한다") + void registerLike_whenNoAuthentication_returnsUnauthorized() { + Long productId = createProduct("좋아요 상품", 10_000, 50); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(null), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + @DisplayName("DELETE /api/v1/products/{productId}/likes") + class Cancel { + + @Test + @DisplayName("좋아요 상태에서 삭제 요청하면 200을 반환한다") + void cancelLike_whenLikedProduct_returnsOk() { + registerMember("likeApiCancelMem", "Password1!", "홍길동", "19900101", "api-like-cancel@example.com", "010-6789-0123"); + Long productId = createProduct("좋아요 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiCancelMem", "Password1!"); + int beforeLikeCount = getProductLikeCount(productId); + + testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getProductLikeCount(productId)).isEqualTo(beforeLikeCount); + } + + @Test + @DisplayName("좋아요가 없는 상품 취소 요청은 404을 반환한다") + void cancelLike_whenNotLikedProduct_returnsNotFound() { + registerMember("likeApiCancelMissMem", "Password1!", "홍길동", "19900101", "api-like-cancel-missing@example.com", "010-7890-1233"); + Long productId = createProduct("좋아요 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiCancelMissMem", "Password1!"); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("인증 정보가 없으면 401을 반환한다") + void cancelLike_whenNoAuthentication_returnsUnauthorized() { + Long productId = createProduct("좋아요 상품", 10_000, 50); + + ResponseEntity> response = testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.DELETE, + new HttpEntity<>(null), + new ParameterizedTypeReference>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + @DisplayName("GET /api/v1/me/likes") + class MyLikes { + + @Test + @DisplayName("인증된 사용자가 기본 페이지/사이즈로 조회하면 200을 반환한다") + void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() { + registerMember("likeApiMeLikes", "Password1!", "홍길동", "19900101", "api-like-mylikes@example.com", "010-9012-3456"); + Long productId = createProduct("좋아요 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiMeLikes", "Password1!"); + + testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME_LIKES, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isInstanceOf(java.util.Map.class); + + @SuppressWarnings("unchecked") + java.util.Map data = (java.util.Map) response.getBody().data(); + assertThat(data.get("page")).isEqualTo(0); + assertThat(data.get("size")).isEqualTo(20); + assertThat(data.get("totalElements")).isEqualTo(1); + + @SuppressWarnings("unchecked") + java.util.List> items = (java.util.List>) data.get("items"); + assertThat(items).hasSize(1); + assertThat(((Number) items.get(0).get("id")).longValue()).isEqualTo(productId); + } + + @Test + @DisplayName("좋아요한 상품이 삭제되면 내 좋아요 목록에서 제외된다") + void getMyLikes_whenLikedProductDeleted_excludesDeletedProduct() { + registerMember("likeApiMeDeleted", "Password1!", "홍길동", "19900101", "api-like-mylikes-deleted@example.com", "010-9022-3456"); + Long productId = createProduct("삭제될 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiMeDeleted", "Password1!"); + + testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + deleteProductAsAdmin(productId); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME_LIKES, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isInstanceOf(java.util.Map.class); + + @SuppressWarnings("unchecked") + java.util.Map data = (java.util.Map) response.getBody().data(); + assertThat(data.get("totalElements")).isEqualTo(0); + + @SuppressWarnings("unchecked") + java.util.List> items = (java.util.List>) data.get("items"); + assertThat(items).isEmpty(); + } + + @Test + @DisplayName("인증 정보가 없으면 401을 반환한다") + void getMyLikes_whenNoAuthentication_returnsUnauthorized() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME_LIKES, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders headers(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private String productLikesUrl(long productId) { + return ENDPOINT_PRODUCTS + "/" + productId + ENDPOINT_LIKES; + } + + private int getProductLikeCount(long productId) { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + productId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + return response.getBody().data().path("likeCount").asInt(); + } + + private void deleteProductAsAdmin(long productId) { + ProductEntity product = productJpaRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("product not found: " + productId)); + product.delete(); + productJpaRepository.save(product); + } + + private void registerMember(String loginId, String password, String name, String birthDate, String email, String phone) { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + loginId, + password, + name, + birthDate, + email, + phone + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() { + } + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + private Long createProduct(String name, int price, int stock) { + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + name, + price, + stock, + "desc", + categoryId, + brandId + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNotNull(); + return response.getBody().data().id(); + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } + + private Long createBrand(String name) { + return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java new file mode 100644 index 000000000..d3a935468 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java @@ -0,0 +1,298 @@ +package com.loopers.interfaces.api.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.interfaces.api.member.MemberDto; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Like API Controller Tests") +class LikeControllerTest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + private Long brandId; + private Long categoryId; + + @BeforeEach + void setUp() { + brandId = createBrand("LIKE_TEST_BRAND"); + categoryId = createCategory("LIKE_TEST_CATEGORY"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/products/{productId}/likes") + class RegisterLike { + + @Test + @DisplayName("인증된 사용자가 활성 상품을 좋아요하면 201을 반환한다") + void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() throws Exception { + registerMember("likeMember", "Password1!", "홍길동", "19900101", "like@example.com", "010-1234-5678"); + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "likeMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + } + + @Test + @DisplayName("이미 좋아요한 상품을 다시 요청하면 409을 반환한다") + void registerLike_whenAlreadyLikedProduct_returnsConflict() throws Exception { + registerMember("likeConflictMember", "Password1!", "홍길동", "19900101", "like2@example.com", "010-2345-6789"); + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "likeConflictMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "likeConflictMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("존재하지 않는 상품 ID면 404을 반환한다") + void registerLike_whenProductNotFound_returnsNotFound() throws Exception { + registerMember("likeMissingMem", "Password1!", "홍길동", "19900101", "missing@example.com", "010-3456-7890"); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", 0L) + .header(HEADER_LOGIN_ID, "likeMissingMem") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("삭제된 상품은 400 Bad Request를 반환한다") + void registerLike_whenDeletedProduct_returnsBadRequest() throws Exception { + registerMember("likeDeletedMem", "Password1!", "홍길동", "19900101", "deleted@example.com", "010-4567-8901"); + Long productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + deleteProduct(productId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "likeDeletedMem") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("인증이 없으면 401을 반환한다") + void registerLike_whenNoAuthentication_returnsUnauthorized() throws Exception { + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("DELETE /api/v1/products/{productId}/likes") + class CancelLike { + + @Test + @DisplayName("좋아요 상태면 취소하고 200을 반환한다") + void cancelLike_whenLikedProduct_returnsOk() throws Exception { + registerMember("cancelLikeMember", "Password1!", "홍길동", "19900101", "cancel@example.com", "010-5678-9012"); + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "cancelLikeMember") + .header(HEADER_LOGIN_PW, "Password1!")); + + mockMvc.perform(delete("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "cancelLikeMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + } + + @Test + @DisplayName("좋아요가 없으면 404을 반환한다") + void cancelLike_whenNotLikedProduct_returnsNotFound() throws Exception { + registerMember("cancelMissingMem", "Password1!", "홍길동", "19900101", "cancel-missing@example.com", "010-6789-0123"); + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(delete("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "cancelMissingMem") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("인증이 없으면 401을 반환한다") + void cancelLike_whenNoAuthentication_returnsUnauthorized() throws Exception { + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(delete("/api/v1/products/{productId}/likes", productId)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /api/v1/me/likes") + class GetMyLikes { + + @Test + @DisplayName("인증된 사용자가 기본 페이지/사이즈로 조회하면 200을 반환한다") + void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() throws Exception { + registerMember("myLikesMember", "Password1!", "홍길동", "19900101", "mylikes@example.com", "010-7890-1234"); + Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "myLikesMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/v1/me/likes") + .param("page", "0") + .param("size", "20") + .header(HEADER_LOGIN_ID, "myLikesMember") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(productId)); + } + + @Test + @DisplayName("좋아요한 상품이 삭제되면 목록에서 제외된다") + void getMyLikes_whenLikedProductDeleted_excludesDeletedProduct() throws Exception { + registerMember("mylikesDeletedMem", "Password1!", "홍길동", "19900101", "mylikes-deleted@example.com", "010-7890-5555"); + Long productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + + mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) + .header(HEADER_LOGIN_ID, "mylikesDeletedMem") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isCreated()); + + deleteProduct(productId); + + mockMvc.perform(get("/api/v1/me/likes") + .param("page", "0") + .param("size", "20") + .header(HEADER_LOGIN_ID, "mylikesDeletedMem") + .header(HEADER_LOGIN_PW, "Password1!")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(0)) + .andExpect(jsonPath("$.data.items").isEmpty()); + } + + @Test + @DisplayName("인증이 없으면 401을 반환한다") + void getMyLikes_whenNoAuthentication_returnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/likes")) + .andExpect(status().isUnauthorized()); + } + } + + private void registerMember(String loginId, String password, String name, String birthDate, String email, String phone) + throws Exception { + MemberDto.RegisterRequest request = new MemberDto.RegisterRequest( + loginId, + password, + name, + birthDate, + email, + phone + ); + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + private void deleteProduct(long productId) { + ProductEntity product = productJpaRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("product not found: " + productId)); + product.delete(); + productJpaRepository.save(product); + } + + private Long createProduct(String name, int price, int stock, String description, Long categoryId, Long brandId) throws Exception { + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + name, + price, + stock, + description, + categoryId, + brandId + ); + + String body = mockMvc.perform(post("/api/v1/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + return objectMapper.readTree(body).path("data").path("id").asLong(); + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } + + private Long createBrand(String name) { + return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java new file mode 100644 index 000000000..6cb28db5b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -0,0 +1,148 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +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.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + + @Autowired + public ProductApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandRepository brandRepository, + CategoryRepository categoryRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("상품 API") + class ProductApi { + + @Test + @DisplayName("상품 생성 후 상세 조회에 성공한다") + void createAndGetDetail() { + Long categoryId = createCategory("푸드"); + Long brandId = createBrand("퍼피박스"); + ProductDto.CreateProductRequest create = new ProductDto.CreateProductRequest( + "강아지 샴푸", + 8900, + 50, + "저자극", + categoryId, + brandId + ); + + ResponseEntity> created = testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(create), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + Long productId = created.getBody().data().id(); + + ResponseEntity> detail = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + productId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(detail.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(detail.getBody().data().name()).isEqualTo("강아지 샴푸"); + } + + @Test + @DisplayName("브랜드 필터로 목록 조회에 성공한다") + void listWithBrandFilter() { + Long categoryId = createCategory("푸드"); + Long brandIdForList = createBrand("퍼피박스"); + Long otherBrandId = createBrand("포메피아"); + create("상품A", 1000, categoryId, brandIdForList); + create("상품B", 2000, categoryId, otherBrandId); + + ResponseEntity> list = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?brandId=" + brandIdForList + "&sort=latest&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(list.getBody().data().totalElements()).isEqualTo(1); + assertThat(list.getBody().data().items().get(0).brandId()).isEqualTo(brandIdForList); + } + } + + private void create(String name, int price, Long categoryId, Long brandId) { + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + name, + price, + 10, + "desc", + categoryId, + brandId + ); + + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() { + } + ); + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } + + private Long createBrand(String name) { + return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java new file mode 100644 index 000000000..0a9ebd0ae --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java @@ -0,0 +1,204 @@ +package com.loopers.interfaces.api.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +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.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/products") + class Create { + + @Test + @DisplayName("유효한 요청이면 201 Created를 반환한다") + void createSuccess() throws Exception { + Long categoryId = createCategory("푸드"); + Long brandId = createBrand("퍼피박스"); + + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + "강아지 간식", + 3500, + 100, + "오리 고구마", + categoryId, + brandId + ); + + mockMvc.perform(post("/api/v1/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").isNumber()) + .andExpect(jsonPath("$.data.name").value("강아지 간식")); + } + + @Test + @DisplayName("카테고리 ID가 없으면 400을 반환한다") + void createFailWhenCategoryIdMissing() throws Exception { + Long brandId = createBrand("퍼피박스"); + + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + "강아지 장난감", + 3000, + 50, + "오리", + null, + brandId + ); + + mockMvc.perform(post("/api/v1/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("브랜드 ID가 없으면 400을 반환한다") + void createFailWhenBrandIdMissing() throws Exception { + Long categoryId = createCategory("푸드"); + + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + "강아지 목줄", + 2500, + 20, + "편안한 소재", + categoryId, + null + ); + + mockMvc.perform(post("/api/v1/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/v1/products/{id}") + class GetDetail { + + @Test + @DisplayName("존재하는 상품이면 200과 상품 정보를 반환한다") + void getDetailSuccess() throws Exception { + Long categoryId = createCategory("푸드"); + Long brandId = createBrand("퍼피박스"); + Long productId = createProduct("사료A", 12000, 30, "설명", categoryId, brandId); + + mockMvc.perform(get("/api/v1/products/{id}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(productId)) + .andExpect(jsonPath("$.data.name").value("사료A")); + } + + @Test + @DisplayName("존재하지 않는 상품이면 404를 반환한다") + void getDetailNotFound() throws Exception { + mockMvc.perform(get("/api/v1/products/{id}", 0L)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/products") + class GetList { + + @Test + @DisplayName("브랜드 필터로 목록을 조회한다") + void listByBrandFilter() throws Exception { + Long categoryId = createCategory("푸드"); + Long brandIdForList = createBrand("퍼피박스"); + Long otherBrandId = createBrand("포메피아"); + + createProduct("사료A", 10000, 10, "설명", categoryId, brandIdForList); + createProduct("사료B", 11000, 10, "설명", categoryId, otherBrandId); + + mockMvc.perform(get("/api/v1/products") + .param("brandId", brandIdForList.toString()) + .param("sort", "latest") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.items[0].brandId").value(brandIdForList)) + .andExpect(jsonPath("$.data.items[0].categoryId").value(categoryId)); + } + } + + private Long createProduct(String name, int price, int stock, String description, Long categoryId, Long brandId) throws Exception { + ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( + name, + price, + stock, + description, + categoryId, + brandId + ); + + String body = mockMvc.perform(post("/api/v1/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + return objectMapper.readTree(body).path("data").path("id").asLong(); + } + + private Long createCategory(String name) { + return categoryRepository.save(new Category(name)).id(); + } + + private Long createBrand(String name) { + return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java new file mode 100644 index 000000000..73c1b3890 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.common.PaginationQuery; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductQueryTest { + + @Test + @DisplayName("sort 값이 비어 있으면 최신순 정렬을 사용한다") + void defaultSortIsLatest() { + ProductSortType resolved = ProductSortType.from(null); + assertThat(resolved).isEqualTo(ProductSortType.LATEST); + } + + @Test + @DisplayName("허용된 sort 값은 매핑된다") + void mapSupportedSortValues() { + assertThat(ProductSortType.from("price_asc")).isEqualTo(ProductSortType.PRICE_ASC); + assertThat(ProductSortType.from("likes_desc")).isEqualTo(ProductSortType.LIKES_DESC); + } + + @Test + @DisplayName("지원하지 않는 sort 값은 400 에러를 발생시킨다") + void invalidSortValueFailsWithBadRequest() { + assertThatThrownBy(() -> ProductSortType.from("invalid")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("리스트 쿼리는 기본 페이지 값으로 Pageable을 만든다") + void listQueryCreatesDefaultPageable() { + ProductListQuery query = new ProductListQuery(null, null, null, null); + Pageable pageable = query.toPageable(); + + assertThat(pageable.getPageNumber()).isEqualTo(PaginationQuery.DEFAULT_PAGE); + assertThat(pageable.getPageSize()).isEqualTo(PaginationQuery.DEFAULT_SIZE); + assertThat(pageable).isInstanceOf(PageRequest.class); + assertThat(pageable.getSort()).isEqualTo(ProductSortType.LATEST.toSort()); + } + + @Test + @DisplayName("페이지 값이 음수면 400 에러를 발생시킨다") + void invalidPageValueFailsWithBadRequest() { + assertThatThrownBy(() -> new PaginationQuery(-1, 20)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("크기가 1 미만이면 400 에러를 발생시킨다") + void invalidSizeValueFailsWithBadRequest() { + assertThatThrownBy(() -> new PaginationQuery(0, 0)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } +} diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 000000000..841d87932 --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,160 @@ +# 🐾 PawShop — 감성 애견용품 이커머스 + +## 🎯 배경 + +**좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 애견용품 이커머스**. + +내가 좋아하는 브랜드의 애견용품들을 한 번에 담아 주문하고, 멤버 행동은 랭킹과 추천으로 연결돼요. + +우린 이 흐름을 하나씩 직접 만들어갈 거예요. + +--- + +## 🧭 서비스 흐름 예시 + +1. 사용자가 **회원가입**을 하고 +2. 여러 브랜드의 애견용품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. +3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. +4. 멤버의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. + +--- + +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ``` + 멤버 로그인이 필요한 기능은 아래 헤더를 통해 멤버를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 멤버는 타 멤버의 정보에 직접 접근할 수 없습니다. + + * X-Loopers-LoginId : 로그인 ID + * X-Loopers-LoginPw : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ``` + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * X-Loopers-Ldap : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + +--- + +## ✅ 요구사항 + +## 👤 멤버 (Members) + +| **METHOD** | **URI** | **member_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/members` | X | 회원가입 | +| GET | `/api/v1/members/me` | O | 내 정보 조회 | +| PUT | `/api/v1/members/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **member_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 애견용품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 애견용품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 정렬 기준은 선택 구현입니다. +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> 결제 로직은 나중에 고려합니다 +> 쿠폰 로직은 나중에 고려합니다 + + +반드시 포함되어야 할 설계 내용 +1. 브랜드 및 상품: 정보 조회, 목록 조회(정렬/필터 포함), 어드민용 CRUD. +2. 좋아요: 상품 좋아요 등록/취소(토글 방식 지양), 좋아요 목록 조회. +3. 주문(Order): 주문 요청, 멤버 주문 목록/상세 조회. 특히 주문 당시의 상품 정보(스냅샷) 저장 구조 설계가 중요합니다. +4. 어드민: 상품/브랜드/주문 관리를 위한 어드민 기능 및 X-Loopers-Ldap 헤더를 이용한 인증 설계. + + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | 등록된 브랜드 목록 조회 | +| GET | `/api-admin/v1/brands/{brandId}` | O | 브랜드 상세 조회 | +| POST | `/api-admin/v1/brands` | O | 브랜드 등록 | +| PUT | `/api-admin/v1/brands/{brandId}` | O | 브랜드 정보 수정 | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | 브랜드 삭제 — 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | 등록된 상품 목록 조회 | +| GET | `/api-admin/v1/products/{productId}` | O | 상품 상세 조회 | +| POST | `/api-admin/v1/products` | O | 상품 등록 — 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | 상품 정보 수정 — 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | 상품 삭제 | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **member_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/members/{memberId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **member_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 멤버의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +> **주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +> **주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +--- + +## 📡 나아가며 + +> ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** diff --git a/docs/ai-rules/README.md b/docs/ai-rules/README.md new file mode 100644 index 000000000..27dbf360f --- /dev/null +++ b/docs/ai-rules/README.md @@ -0,0 +1,15 @@ +# AI Rules (Shared) + +이 디렉터리는 Claude/Codex 등 에이전트 공용 규칙 문서를 저장한다. + +## Files +- `coding-style.md`: 코딩 스타일 및 설계/구현 기본 규칙 +- `git-workflow.md`: Git 브랜치/커밋/PR 작업 규칙 +- `testing.md`: 테스트 작성/실행 기준 +- `performance.md`: 성능 점검 및 최적화 기준 +- `security.md`: 보안 관련 점검 기준 + +## Usage +- 프로젝트 진입 규칙은 `AGENTS.md`를 따른다. +- `AGENTS.md`가 작업 유형별로 이 디렉터리 파일을 참조한다. +- 기존 `.claude/rules`는 호환성을 위해 유지할 수 있으나, 신규 수정은 이 디렉터리를 기준으로 한다. diff --git a/docs/ai-rules/coding-style.md b/docs/ai-rules/coding-style.md new file mode 100644 index 000000000..c8b8026d1 --- /dev/null +++ b/docs/ai-rules/coding-style.md @@ -0,0 +1,54 @@ +# 코딩 스타일 규칙 (Java / Spring) + +## 일반 원칙 +- 불변성 우선 → `final`, 불변 객체 설계 +- 함수는 SRP 준수 +- 매직 넘버/문자열 → 상수로 추출 +- 과도한 추상화 금지 → 3줄 유사 코드가 조기 추상화보다 낫다 +- 사용하지 않는 코드 완전 삭제 (주석 처리 금지) +- 코드 뎁스 1로 제한 + +## Java 기본 +- `final` 우선 → 변수, 파라미터, 필드 모두 +- null 반환 금지 → `Optional` 또는 빈 컬렉션 반환 +- `Optional` → 반환 타입으로만 사용 (생성자, 수정자, 메서드 파라미터 전달 금지) +- `Optional` 변수에 null 할당 금지 → `Optional.empty()` 사용 +- 단순히 값을 얻으려는 목적으로만 `Optional` 사용 금지 +- 컬렉션을 `Optional`로 감싸지 말 것 → 빈 컬렉션 반환 +- `record` 활용 → 불변 DTO +- Stream API 적극 활용, 단 가독성 우선 + +## 네이밍 +- 클래스 → PascalCase +- 메서드·변수 → camelCase +- 상수 → UPPER_SNAKE_CASE +- 패키지 → lowercase + +## Spring 레이어 규칙 +- Facade는 여러 Application Service를 조합/오케스트레이션할 때만 도입한다 +- Facade → Application Service만 호출 (Repository/Domain Service 직접 접근 금지) +- 단일 Application Service 호출만 필요한 유스케이스는 Controller → Application Service로 직접 연결한다 +- Application Service → 자기 도메인 Repository와 Domain Service만 접근 +- Application Service 간 직접 호출 금지 → 크로스 도메인 협력은 Facade 경유 +- Domain Service → 순수 비즈니스 규칙만 수행 (저장/외부 I/O/트랜잭션 금지) +- Controller → 요청/응답 변환만, 비즈니스 로직 금지 +- `@Transactional` → Application Service에만 (Facade/Domain Service 절대 금지) + +## Validation & Consistency +- API DTO에서 기본 Bean Validation(`@NotBlank`, `@Pattern`, `@Email`)은 허용한다 +- 비즈니스 규칙 검증의 최종 책임은 Domain VO/Entity에 둔다 (중복 검증 허용) +- `exists -> save`만으로 중복 방어했다고 판단하지 않는다 +- 유니크 보장은 DB 제약으로 강제하고, 저장 시점 중복 키 예외를 `409(CONFLICT)`로 변환한다 + +## Project-specific Security/Auth Rules +- `password`, `token`, `secret` 필드가 있는 Command/DTO는 `toString()`에서 민감정보를 반드시 마스킹한다 +- 평문 비밀번호를 로그/예외 메시지에 노출하지 않는다 +- 비밀번호 정책 검증(예: 생년월일 포함 금지)은 raw password에서만 수행하고, encoded password는 정책 검증 대상에서 제외한다 +- 비밀번호 변경은 `@AuthMember` 기반 단일 인증만 사용한다 (동일 유스케이스에서 body 재인증 금지) +- 문자열 정규화/예약어 검증 시 Locale 의존 로직을 금지하고 `Locale.ROOT`를 사용한다 + +## 금지 +- `System.out.println` 남기지 말 것 +- `@SuppressWarnings` 남용 금지 +- 불필요한 `private` 함수 지양 → 객체지향적 설계로 대체 +- unused import 즉시 제거 diff --git a/docs/ai-rules/git-workflow.md b/docs/ai-rules/git-workflow.md new file mode 100644 index 000000000..080a17fc1 --- /dev/null +++ b/docs/ai-rules/git-workflow.md @@ -0,0 +1,61 @@ +# Git 워크플로우 규칙 + +## 리포지토리 구조 +- **origin** (fork): `https://github.com/shAn-kor/loop-pack-be-l2-vol3-java` +- **upstream**: `https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java` +- 작업 기준 브랜치: `shAn-kor` + +## 워크플로우 순서 +1. `shAn-kor` 기반으로 기능 단위 worktree 생성 +2. worktree에서 작업 완료 후 `shAn-kor` 브랜치에 머지 +3. `shAn-kor` → `origin` push +4. PR 초안 생성 (`shAn-kor` → upstream `shAn-kor`) → **내용 수정 및 PR 제출은 개발자가 직접** + +## 워크트리 작업 방식 +- 작은 기능 단위로 `git worktree add` → 격리된 디렉토리에서 작업 +- 작업 완료 후 `shAn-kor`에 머지 → `git worktree remove` +- 워크트리당 브랜치 1개 원칙 + +```bash +# 워크트리 생성 (shAn-kor 기반) +git worktree add ../worktree-feature-xxx feature/xxx + +# shAn-kor에 머지 후 제거 +git worktree remove ../worktree-feature-xxx +``` + +## 브랜치 전략 +- `shAn-kor` → 작업 통합 브랜치 (upstream PR 소스) +- `feature/*` → 기능 개발 (worktree 단위) +- `fix/*` → 버그 수정 +- `hotfix/*` → 긴급 수정 + +## 커밋 메시지 (`~/.gitmessage` 기반) +- 형식: `type(scope): 설명` +- 제목 50자 이내, 명령문·현재 시제 +- 본문: 무엇을, 왜 변경했는지 +- 푸터: `Breaking Changes:` / `Closes #이슈번호` + +| type | 용도 | +|------|------| +| feat | 새 기능 | +| fix | 버그 수정 | +| refactor | 리팩토링 (기능 변경 X) | +| style | 포맷팅 (코드 변경 X) | +| docs | 문서 수정 | +| test | 테스트 추가/수정 | +| chore | 빌드·설정 파일 수정 | +| perf | 성능 개선 | +| ci | CI 설정 변경 | + +## PR 규칙 (`.github/pull_request_template.md` 기반) +- **PR은 초안(draft)만 생성** → 내용 수정 및 제출은 개발자가 직접 +- 방향: `shAn-kor/loop-pack-be-l2-vol3-java:shAn-kor` → `Loopers-dev-lab/loop-pack-be-l2-vol3-java:shAn-kor` +- PR 제목 → 커밋 메시지 형식과 동일 +- 스쿼시 머지 선호 + +### PR 본문 필수 섹션 +- **Summary** → 배경 / 목표 / 결과 3~5줄 +- **Context & Decision** → 문제 정의, 선택지와 결정, 트레이드오프 +- **Design Overview** → 변경 범위(모듈/도메인), 주요 컴포넌트 책임 +- **Flow Diagram** → Mermaid 시퀀스 또는 플로우 다이어그램 (핵심 경로 우선) diff --git a/docs/ai-rules/performance.md b/docs/ai-rules/performance.md new file mode 100644 index 000000000..8e156569a --- /dev/null +++ b/docs/ai-rules/performance.md @@ -0,0 +1,46 @@ +# 성능 최적화 기준 + +## 모델 선택 + +| 작업 유형 | 기본 모델 ID | 사용 기준 | +|----------|--------------|----------| +| 기본 코드 / 테스트코드 / 리팩터링 수행 등 간단한 작업 | `gpt-5.3-codex-spark` | 기본값 (토큰 비용 절약 우선) | +| 복잡 디버깅 / 설계 의사 결정 | `GPT-5.1-Codex-Max` | 난이도 높거나 판단 비용이 큰 경우만 | + +### 실행 규칙 +- 기본은 항상 `gpt-5.3-codex-spark`로 시작한다. +- 작업 중 복잡도가 높아질 때만 `GPT-5.1-Codex-Max`로 승급한다. +- 승급 작업 완료 후 후속 구현/리팩터링은 다시 `gpt-5.3-codex-spark`로 복귀한다. + +### 호출 예시 (Codex / OhMyOpenCode / OpenCode 인식용) + +```md +# Codex MCP - 기본 +mcp__codex__codex( + prompt: "구현 내용", + model: "gpt-5.3-codex-spark" +) + +# Codex MCP - 복잡 디버깅 / 설계 +mcp__codex__codex( + prompt: "복잡 이슈 분석", + model: "gpt-5.1-codex-max" +) + +# OpenCode / OhMyOpenCode 설정 예시 +model = "gpt-5.3-codex-spark" +# 필요 시 일시 승급 +# model = "gpt-5.1-codex-max" +``` + +## 코드 성능 +- N+1 쿼리 방지 (ORM 사용 시 특히 주의) +- 불필요한 리렌더링 방지 (React: useMemo, useCallback 적절히 사용) +- 대용량 데이터는 페이지네이션/스트리밍 처리 +- 이미지는 lazy loading 적용 +- 번들 사이즈 최적화 (코드 스플리팅, 트리 쉐이킹) + +## 컨텍스트 최적화 +- 동시에 80개 미만의 도구만 유지 +- MCP 서버는 프로젝트별 5~6개만 활성화 +- 사용하지 않는 MCP 서버는 `disabledMcpServers`에 명시적 비활성화 diff --git a/docs/ai-rules/security.md b/docs/ai-rules/security.md new file mode 100644 index 000000000..66d1079c1 --- /dev/null +++ b/docs/ai-rules/security.md @@ -0,0 +1,9 @@ +# 보안 규칙 + +- 시크릿 하드코딩 금지 → 환경변수 사용 +- `.env` → 항상 `.gitignore` 포함 +- SQL → 파라미터 바인딩만 사용 +- 사용자 입력 → 반드시 sanitize +- CORS → 최소 권한 +- `--dangerously-skip-permissions` 절대 금지 +- 의존성 취약점 정기 검사 (`npm audit`, `pip audit`) diff --git a/docs/ai-rules/testing-recipes/README.md b/docs/ai-rules/testing-recipes/README.md new file mode 100644 index 000000000..cf310e240 --- /dev/null +++ b/docs/ai-rules/testing-recipes/README.md @@ -0,0 +1,12 @@ +# Testing Recipes + +도메인별 테스트코드 작성 기준 모음. + +## Usage +- 먼저 `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing.md`를 따른다. +- 대상 도메인 레시피를 읽고 필수 케이스를 반영한다. + +## Recipes +- `user.md`: 회원/인증/비밀번호/마스킹 관련 +- `order.md`: 주문 생성/취소/상태 전이/재고 보상 관련 +- `product-like.md`: 상품 조회/좋아요/카운트 정합성 관련 diff --git a/docs/ai-rules/testing-recipes/member.md b/docs/ai-rules/testing-recipes/member.md new file mode 100644 index 000000000..7066f41ad --- /dev/null +++ b/docs/ai-rules/testing-recipes/member.md @@ -0,0 +1,21 @@ +# Member Domain Recipe + +## Scope +- 회원가입, 인증, 내 정보 조회, 비밀번호 변경 + +## Mandatory Cases +- 회원가입 성공 +- 중복 아이디 충돌(사전 중복 + 저장 시점 중복키 예외) +- 비밀번호 정책 검증 (길이/문자조합/생년월일 포함 금지) +- raw/encoded 경로 분리 검증 +- 비밀번호 변경 성공/동일 비밀번호 실패/멤버 없음 +- 이름 마스킹 경계값(1글자, 2글자, 3글자) +- 인증 실패(헤더 누락/비밀번호 불일치) + +## Assertions +- 상태 검증 우선 (응답 코드, 저장 결과, 변경된 필드) +- 협력 호출 검증은 필요 최소(예: save 호출 유무) + +## Data Guidance +- encoded password fixture는 BCrypt prefix 포함 (`$2a$`, `$2b$`, `$2y$`) +- 민감정보가 `toString()`에 노출되지 않는지 확인 diff --git a/docs/ai-rules/testing-recipes/order.md b/docs/ai-rules/testing-recipes/order.md new file mode 100644 index 000000000..e3495ffe4 --- /dev/null +++ b/docs/ai-rules/testing-recipes/order.md @@ -0,0 +1,20 @@ +# Order Domain Recipe + +## Scope +- 주문 생성, 주문 취소, 주문 상태 전이, 재고 반영 + +## Mandatory Cases +- 주문 생성 성공 (다건 아이템 포함) +- 삭제된 상품 포함 시 실패 +- 재고 부족 시 실패 +- 주문 취소 성공 +- 이미 취소된 주문 취소 시 실패(충돌) +- 권한 조건 분기(본인/관리자) 검증 + +## Cross-domain Concerns +- 주문 저장 실패 시 재고 보상 필요 여부를 테스트 시나리오로 명시 +- 스냅샷 필드(상품명/가격/브랜드명) 보존 확인 + +## Assertions +- 상태 검증 우선 (주문 상태, 재고 수량, 응답 코드) +- 호출 순서 검증은 정말 필요한 케이스에서만 최소 사용 diff --git a/docs/ai-rules/testing-recipes/product-like.md b/docs/ai-rules/testing-recipes/product-like.md new file mode 100644 index 000000000..03c343a1a --- /dev/null +++ b/docs/ai-rules/testing-recipes/product-like.md @@ -0,0 +1,19 @@ +# Product / Like Recipe + +## Scope +- 상품 목록 조회, 좋아요 등록/취소, likeCount 반영 + +## Mandatory Cases +- 상품 목록 조회 성공 (필터/정렬/페이지네이션) +- 존재하지 않는 필터 조건에서 빈 결과 반환 +- 좋아요 등록 성공 +- 중복 좋아요 충돌 +- 좋아요 취소 성공/미존재 좋아요 취소 실패 +- likeCount 증감 정합성 + +## Assertions +- 조회 API: 응답 구조 + 핵심 필드 + 페이징 정보 +- 좋아요 API: 상태코드 + 카운트 변화 + +## Risk Checks +- likeCount와 실제 Like 데이터 불일치 가능성을 회귀 케이스로 추가 diff --git a/docs/ai-rules/testing.md b/docs/ai-rules/testing.md new file mode 100644 index 000000000..b97ecdf05 --- /dev/null +++ b/docs/ai-rules/testing.md @@ -0,0 +1,53 @@ +# 테스트 규칙 + +## 원칙 +- TDD → Red > Green > Refactor 순서 준수 +- 3A 원칙 → Arrange / Act / Assert +- 가능한 행위 검증보다 상태 검증을 우선한다 +- 단위 테스트는 Classist 기준을 따른다 (테스트 대상 클래스 1개를 협력 객체와 격리) +- 커버리지 목표 → 80% 이상 +- CI → 모든 테스트 통과 필수 + +## 테스트 작성 +- 테스트명 → `메서드명_조건_기대결과` 형식 +- 새 기능 → 레이어 정책에 맞는 테스트 함께 작성 +- 버그 수정 → 재현 테스트 먼저 작성 +- 외부 의존성 → `@MockitoBean` / `Mockito.mock()` 처리 + +## 프로젝트 필수 회귀 테스트 +- 보안 회귀: `toString()` 민감정보 마스킹 테스트 필수 +- 비밀번호 정책: raw/encoded 경로 분리 테스트 필수 +- 회원가입 중복: 저장 시점 중복키 예외(동시성 경합) 테스트 필수 +- 이름 마스킹 경계값: 1/2/3글자 테스트 필수 + +## 도메인별 레시피 +- 공통 인덱스: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/README.md` +- 멤버 도메인: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/member.md` +- 주문 도메인: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/order.md` +- 상품/좋아요 도메인: `/Users/anseonghun/Documents/project/loop-pack-be-l2-vol3-java/docs/ai-rules/testing-recipes/product-like.md` + +## 레이어별 테스트 전략 +- Domain / Domain Service → 단위 테스트 +- Application Service → 통합 테스트 +- Controller → 통합 테스트 +- 핵심 사용자 시나리오 → E2E 테스트 필수 + +## 단위 테스트(Classist) 규칙 +- 테스트 대상(SUT)은 1개 클래스만 둔다 +- 협력 객체(Repository, 외부 API, 메시징, Clock 등)는 Mock/Stub으로만 격리한다 (Fake 사용 금지) +- 단위 테스트에서 DB/네트워크/파일 I/O를 직접 사용하지 않는다 +- 상태 검증을 우선하되, 외부 협력 호출은 필요한 경우에만 최소한으로 검증한다 +- private 메서드/구현 디테일을 직접 검증하지 않는다 + +## Spring 테스트 도구 가이드 +- 단위 테스트 → `@ExtendWith(MockitoExtension.class)` +- 통합 테스트 → `@SpringBootTest` + Testcontainers +- 통합 테스트 DB는 개발/운영과 동일한 엔진/버전 이미지를 사용한다 +- E2E 테스트 → 실제 API 경로 기준으로 시나리오 검증 +- DB 격리 → `@Transactional` 또는 `@Sql` 로 초기화 +- API 검증 → `MockMvc` 사용 + +## 금지 +- 통합 테스트에서 H2 인메모리 DB 사용 금지 +- 테스트 간 상태 공유 금지 +- `println` 디버깅 코드 남기지 말 것 diff --git a/docs/checklist.md b/docs/checklist.md new file mode 100644 index 000000000..0c4b74525 --- /dev/null +++ b/docs/checklist.md @@ -0,0 +1,79 @@ +## ✅ Checklist + +### 🏷 Product / Brand 도메인 + +- [ ] 상품 응답 DTO는 브랜드 정보, 좋아요 수를 포함한다 (엔티티 필드 직접 포함이 아님) +- [ ] Product JPA 엔티티는 `brandId`(FK)로 Brand와 논리적으로 연결된다 +- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [ ] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 +- [ ] 재고의 음수 방지 처리는 도메인 레벨에서 처리된다 +- [ ] Brand name은 불변이고, 수정은 description/imageUrl만 허용한다 +- [ ] Product의 `brandId`는 수정 불가 규칙을 반영했다 +- [ ] 고객 API는 Soft Delete 상품 제외, 어드민 API는 삭제 정보 포함 정책을 반영했다 + +### 👍 Like 도메인 + +- [ ] 좋아요는 멤버와 상품 간의 관계로 별도 도메인으로 분리했다 +- [ ] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 +- [ ] 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다 +- [ ] 좋아요는 토글이 아닌 등록(POST)/취소(DELETE)로 분리했다 +- [ ] `user_id + product_id` 유니크 제약으로 중복 좋아요를 방지한다 +- [ ] 삭제된 상품 좋아요 요청 차단(400) 규칙을 반영했다 + +### 🛒 Order 도메인 + +- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [ ] 주문 시 상품의 재고 차감을 수행한다 +- [ ] 재고 부족 예외 흐름을 고려해 설계되었다 +- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 +- [ ] 주문 시점 스냅샷(상품명/가격/브랜드명)을 OrderItem에 저장한다 +- [ ] 일부 상품 실패 시 전체 주문 실패(부분 성공 없음) 정책을 반영했다 +- [ ] 주문 취소 시 상태 전이(ORDERED -> CANCELLED) 및 재고 복원을 반영했다 +- [ ] 이미 취소된 주문 재취소는 409로 처리한다 +- [ ] 주문 상세/목록 조회는 스냅샷 기준으로 응답한다 +- [ ] 주문 목록 조회는 기간(startAt/endAt) + 페이지네이션을 반영한다 + +### 🧩 도메인 서비스 + +- [ ] 엔티티/VO 단독으로 표현하기 어려운 도메인 내부 규칙만 Domain Service에 둔다 +- [ ] 엔티티/VO가 자체 책임질 수 있는 규칙은 Entity/VO에 둔다 +- [ ] 상품 상세 조회 시 Product + Brand 정보 조합은 Application Layer 에서 처리했다 +- [ ] 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다 +- [ ] 도메인 서비스는 상태 없이, 동일한 도메인 경계 내의 도메인 객체의 협력 중심으로 설계되었다 + +### **🧱 소프트웨어 아키텍처 & 설계** + +- [ ] 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다 + - Application → **Domain** ← Infrastructure +- [ ] Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다 +- [ ] 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다 +- [ ] Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다 +- [ ] 패키지는 계층 + 도메인 기준으로 구성되었다 (`/domain/order`, `/application/like` 등) +- [ ] 단일 Application Service 호출 유스케이스는 Controller -> Application Service로 직접 연결한다 +- [ ] Facade는 여러 Application Service 조합/오케스트레이션이 필요한 경우에만 사용한다 +- [ ] `@Transactional`은 Application Service에만 둔다 +- [ ] 주문 유스케이스는 단일 트랜잭션 경계에서 재고확인 -> 차감 -> 주문생성 흐름을 보장한다 + +### 👤 Member / 인증 + +- [ ] 회원가입 입력 규칙(loginId/password/name/email/birthDate)을 반영했다 +- [ ] 사전 중복 체크 + 저장 시점 중복키 예외를 모두 409로 변환한다 +- [ ] 비밀번호 정책 검증은 raw password에서만 수행한다 +- [ ] encoded password 경로는 정책 검증 대상에서 제외한다 +- [ ] 이름 마스킹 규칙(1/2/3글자 경계 포함)을 반영했다 +- [ ] 내 정보/비밀번호 변경은 `@AuthMember` 단일 인증으로 처리한다 + +### 🔐 Admin / 권한 + +- [ ] 어드민 API는 `X-Loopers-Ldap` 헤더 기반 인증을 사용한다 +- [ ] 고객 API(`/api/v1`)와 어드민 API(`/api-admin/v1`) 경계를 분리했다 +- [ ] 브랜드/상품/주문 어드민 조회 기능(목록/상세)을 반영했다 + +### 🧪 테스트 전략 + +- [ ] Domain/Domain Service는 단위 테스트(Classist)로 검증한다 +- [ ] Application Service/Controller는 통합 테스트로 검증한다 +- [ ] 핵심 시나리오는 E2E 테스트로 검증한다 +- [ ] 통합 테스트는 Testcontainers 기반으로 운영과 동일 엔진/버전을 사용한다 +- [ ] 보안 회귀: `toString` 민감정보 마스킹 테스트를 포함한다 +- [ ] 경계값: 이름 마스킹 1/2/3글자 테스트를 포함한다 diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..1659d5979 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,400 @@ +# PawShop 요구사항 명세서 + +> 생성일: 2026-02-11 +> 핵심 가치: 브랜드별 애견용품을 탐색-좋아요-주문하는 핵심 쇼핑 플로우를 제공하고, 멤버 행동 데이터를 축적하는 이커머스 백엔드 시스템 구축 + +## 1. 문제 정의 + +- **사용자 관점**: 여러 브랜드의 애견용품을 한 곳에서 비교/선택하고, 관심 상품을 관리(좋아요)하며 편리하게 주문하고 싶다. +- **비즈니스 관점**: 브랜드별 상품 큐레이션과 멤버 행동 데이터(좋아요, 주문)를 축적하여 추후 추천/랭킹 기능의 기반을 마련한다. +- **시스템 관점**: 주문 시 상품 스냅샷과 재고 차감의 데이터 일관성을 보장하면서, 쿠폰/결제/추천 등의 확장에 대비한 백엔드 구조가 아직 없다. + +## 2. 개념 모델 + +### 액터 + +- **고객(Customer)**: 비로그인 방문자 및 로그인 회원 +- **어드민(Admin)**: 사내 관리자 (LDAP 인증) + +### 핵심 도메인 + +- **Member** - 회원가입, 인증, 프로필 관리 +- **Brand** - 브랜드 정보 관리 (이름 불변) +- **Category** - 상품 카테고리 관리 (Seed 데이터 기반 조회 전용 테이블) +- **Product** - 상품 정보 관리 (재고, 가격, 카테고리 참조) +- **Like** - 상품 좋아요 (멤버 행동 데이터 축적) +- **Order** - 주문 처리 (스냅샷, 재고 차감, 상태 관리) + +### 보조/외부 시스템 + +- 없음 (모놀리식, 외부 결제 없음) + +## 3. Member Stories + +### 도메인 1: Member + +**기존 구현 완료** (loginId, password, name, email, birthDate). phone 필드 추가 예정. + +#### US-01: 회원가입 + +**As a** 신규 방문자 +**I want to** loginId, password, name, email, birthDate, phone을 입력하여 회원가입 +**So that** PawShop에서 좋아요/주문 등 회원 전용 기능을 이용할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 입력값, When POST /api/v1/members, Then 201 Created + 멤버 생성 +- [ ] AC2: Given 이미 존재하는 loginId, When POST /api/v1/members, Then 409 Conflict +- [ ] AC3: Given 필수값 누락 또는 형식 오류(email, phone), When POST /api/v1/members, Then 400 Bad Request +- [ ] AC4: Given loginId, When GET 중복검사 API, Then 사용 가능/불가능 응답 + +**비즈니스 규칙:** +- loginId: 4~20자, 영문소문자+숫자, 예약어 제외 +- password: 8~16자, 대/소문자+숫자+특수문자 필수, 생년월일 미포함, BCrypt 암호화 +- name: 한글 최대 4자 또는 영문 최대 50자 (혼용 불가) +- email: RFC 5321 준수 +- birthDate: yyyyMMdd 형식, 미래 날짜 불가 +- phone: 010-XXXX-XXXX 형식 (하이픈 포함 13자) + +#### US-02: 내 정보 조회 + +**As a** 로그인한 회원 +**I want to** 내 정보(loginId, name, email, birthDate, phone)를 조회 +**So that** 내 계정 정보를 확인할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 인증 헤더, When GET /api/v1/members/me, Then 200 + 내 정보 반환 (password 제외, 이름 마스킹) +- [ ] AC2: Given 잘못된 인증 헤더, When GET /api/v1/members/me, Then 401 + +**비즈니스 규칙:** +- 이름은 마지막 글자를 *로 마스킹 (예: "홍길동" → "홍길*") + +#### US-03: 비밀번호 변경 + +**As a** 로그인한 회원 +**I want to** 현재 비밀번호를 확인 후 새 비밀번호로 변경 +**So that** 계정 보안을 유지할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 인증 + 현재PW 일치 + 유효한 새PW, When PATCH /api/v1/members/me/password, Then 200 +- [ ] AC2: Given 현재 비밀번호 불일치, When PATCH, Then 400 +- [ ] AC3: Given 새 비밀번호가 현재와 동일, When PATCH, Then 400 +- [ ] AC4: Given 새 비밀번호가 규칙 미충족, When PATCH, Then 400 + +--- + +### 도메인 2: Brand + +#### US-04: 브랜드 등록 (어드민) + +**As a** 어드민 +**I want to** 새 브랜드(name, description, imageUrl)를 등록 +**So that** 해당 브랜드의 상품을 등록할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 LDAP 헤더 + 유효한 입력값, When POST /api-admin/v1/brands, Then 201 Created +- [ ] AC2: Given LDAP 헤더 없음, When POST, Then 401 +- [ ] AC3: Given 필수값 누락, When POST, Then 400 + +**비즈니스 규칙:** +- name: 등록 후 수정 불가 (불변) +- 필수 속성: name, description, imageUrl + +#### US-05: 브랜드 조회 + +**As a** 고객/어드민 +**I want to** 브랜드 정보를 조회 +**So that** 브랜드 상세 정보를 확인할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given brandId, When GET /api/v1/brands/{brandId}, Then 200 + 브랜드 정보 +- [ ] AC2: Given 존재하지 않는 brandId, When GET, Then 404 +- [ ] AC3: Given LDAP 인증, When GET /api-admin/v1/brands?page=0&size=20, Then 200 + 페이지네이션된 브랜드 목록 +- [ ] AC4: Given LDAP 인증 + brandId, When GET /api-admin/v1/brands/{brandId}, Then 200 + 브랜드 상세 + +#### US-06: 브랜드 수정 (어드민) + +**As a** 어드민 +**I want to** 브랜드의 description, imageUrl을 수정 +**So that** 브랜드 정보를 최신으로 유지할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 LDAP + 유효한 수정값, When PUT /api-admin/v1/brands/{brandId}, Then 200 +- [ ] AC2: Given name 수정 시도, When PUT, Then 400 (name은 수정 불가) + +**비즈니스 규칙:** +- name은 수정 불가. description과 imageUrl만 수정 가능 + +#### US-07: 브랜드 삭제 (어드민) + +**As a** 어드민 +**I want to** 더 이상 필요 없는 브랜드를 삭제 +**So that** 시스템을 깔끔하게 유지할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 브랜드가 존재, When DELETE /api-admin/v1/brands/{brandId}, Then 브랜드와 해당 브랜드의 상품이 함께 soft-delete 처리되고 200 +- [ ] AC2: Given 삭제된 브랜드, When DELETE, Then 404 + +**비즈니스 규칙:** +- 브랜드 삭제는 해당 브랜드를 soft-delete하고, 관련 상품을 함께 soft-delete한다. + +--- + +### 도메인 3: Product + +#### US-08: 상품 등록 (어드민) + +**As a** 어드민 +**I want to** 새 상품(name, price, stock, description, category, brandId)을 등록 +**So that** 고객이 상품을 탐색/구매할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 LDAP + 활성 브랜드 + 유효한 입력값, When POST /api-admin/v1/products, Then 201 +- [ ] AC2: Given 존재하지 않거나 삭제된 브랜드, When POST, Then 400 +- [ ] AC3: Given price가 0 이하, When POST, Then 400 +- [ ] AC4: Given stock이 음수, When POST, Then 400 + +**비즈니스 규칙:** +- 상품은 반드시 활성 상태의 등록된 브랜드에 속해야 함 +- 필수 속성: name, price, stock, description, categoryId +- category는 별도 테이블 (Seed 데이터 기반, 조회 전용) + +#### US-09: 상품 조회 + +**As a** 고객 +**I want to** 상품 목록을 필터/정렬/페이지네이션으로 조회하고, 상세 정보를 확인 +**So that** 원하는 애견용품을 찾을 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 파라미터 없음, When GET /api/v1/products, Then 200 + 기본 정렬(latest), 기본 페이지(0), 기본 사이즈(20) +- [ ] AC2: Given brandId 필터, When GET, Then 해당 브랜드 상품만 반환 (존재하지 않는 brandId면 빈 목록) +- [ ] AC3: Given sort=price_asc, When GET, Then 가격 오름차순 정렬 +- [ ] AC4: Given sort=likes_desc, When GET, Then 좋아요 많은 순 정렬 +- [ ] AC5: Given productId, When GET /api/v1/products/{productId}, Then 200 + 상품 상세 (좋아요 수 포함) +- [ ] AC6: Given 삭제된 상품, When 고객 API 조회, Then 목록에서 제외 +- [ ] AC7: Given 삭제된 상품, When 어드민 API 조회, Then deletedAt 포함하여 노출 + +**쿼리 파라미터:** + +| 파라미터 | 기본값 | 설명 | +|---------|--------|------| +| brandId | - | 특정 브랜드 필터링 | +| sort | latest | latest / price_asc / likes_desc | +| page | 0 | 페이지 번호 | +| size | 20 | 페이지당 상품 수 | + +#### US-10: 상품 수정 (어드민) + +**As a** 어드민 +**I want to** 상품의 name, price, stock, description, category를 수정 +**So that** 상품 정보를 최신으로 유지할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 수정값, When PUT /api-admin/v1/products/{productId}, Then 200 +- [ ] AC2: Given brandId 수정 시도, When PUT, Then 400 (브랜드 수정 불가) + +**비즈니스 규칙:** +- brandId는 수정 불가 + +#### US-11: 상품 삭제 (어드민) + +**As a** 어드민 +**I want to** 상품을 삭제 +**So that** 더 이상 판매하지 않는 상품을 비활성화할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 주문이 없는 상품, When DELETE /api-admin/v1/products/{productId}, Then 200 (Soft Delete) +- [ ] AC2: Given 주문이 있는 상품, When DELETE, Then 409 Conflict + +**비즈니스 규칙:** +- 해당 상품에 주문(OrderItem)이 존재하면 삭제 불가 + +--- + +### 도메인 4: Like + +#### US-12: 상품 좋아요 등록 + +**As a** 로그인한 회원 +**I want to** 마음에 드는 상품에 좋아요를 누르기 +**So that** 관심 상품을 기록하고 나중에 다시 찾을 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증된 멤버 + 활성 상품, When POST /api/v1/products/{productId}/likes, Then 201 + 상품 likeCount 증가 +- [ ] AC2: Given 이미 좋아요한 상품, When POST, Then 409 Conflict +- [ ] AC3: Given 삭제된 상품, When POST, Then 400 Bad Request +- [ ] AC4: Given 존재하지 않는 productId, When POST, Then 404 + +**비즈니스 규칙:** +- DB에 member_id + product_id Unique 제약조건 +- Product 테이블의 likeCount 필드 업데이트 + +#### US-13: 상품 좋아요 취소 + +**As a** 로그인한 회원 +**I want to** 좋아요를 취소 +**So that** 관심 목록을 정리할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 좋아요 상태인 상품, When DELETE /api/v1/products/{productId}/likes, Then 200 + 상품 likeCount 감소 +- [ ] AC2: Given 좋아요하지 않은 상품, When DELETE, Then 404 + +#### US-14: 내 좋아요 목록 조회 + +**As a** 로그인한 회원 +**I want to** 내가 좋아요한 상품 목록을 조회 +**So that** 관심 상품을 한눈에 볼 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증된 멤버, When GET /api/v1/me/likes?page=0&size=20, Then 200 + 좋아요한 활성 상품 목록 (페이지네이션) +- [ ] AC2: Given 좋아요한 상품 중 삭제된 상품, Then 목록에서 제외 + +**쿼리 파라미터:** + +| 파라미터 | 기본값 | 설명 | +|---------|--------|------| +| page | 0 | 페이지 번호 | +| size | 20 | 페이지당 상품 수 | + +--- + +### 도메인 5: Order + +#### US-15: 주문 요청 + +**As a** 로그인한 회원 +**I want to** 여러 상품을 한 번에 주문 +**So that** 원하는 애견용품을 구매할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증 + 모든 상품 활성 + 재고 충분, When POST /api/v1/orders, Then 201 + 주문 생성 + 재고 차감 + 스냅샷 저장 +- [ ] AC2: Given 하나라도 재고 부족, When POST, Then 400 + 전체 실패 (재고 차감 없음) +- [ ] AC3: Given 삭제된 상품 포함, When POST, Then 400 +- [ ] AC4: Given 존재하지 않는 productId 포함, When POST, Then 404 +- [ ] AC5: Given items가 빈 배열, When POST, Then 400 + +**요청 예시:** +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +**비즈니스 규칙:** +- 주문 상태: ORDERED로 생성 +- 스냅샷: 주문 시점의 주문 번호, 상품명, 가격, 브랜드명 저장 +- 하나의 트랜잭션에서 재고 확인 → 차감 → 주문 생성 처리 +- 결제 없음 (주문 완료 = 결제 완료) + +#### US-16: 멤버 주문 취소 + +**As a** 로그인한 회원 +**I want to** 내 주문을 취소 +**So that** 잘못 주문했거나 변심한 경우 취소할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증 + 본인의 ORDERED 상태 주문, When PATCH /api/v1/orders/{orderId}/cancel, Then 200 + 상태 CANCELLED + 재고 복원 +- [ ] AC2: Given 타인의 주문, When PATCH, Then 403 +- [ ] AC3: Given 이미 CANCELLED 상태, When PATCH, Then 409 Conflict +- [ ] AC4: Given 존재하지 않는 orderId, When PATCH, Then 404 + +#### US-17: 멤버 주문 목록 조회 + +**As a** 로그인한 회원 +**I want to** 날짜 범위로 내 주문 목록을 조회 +**So that** 주문 이력을 확인할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증 + 유효한 startAt/endAt (날짜, KST 기준), When GET /api/v1/orders?startAt=...&endAt=...&page=0&size=20, Then 200 + 본인 주문 목록 (페이지네이션) +- [ ] AC2: Given startAt/endAt 누락, When GET, Then 400 +- [ ] AC3: Given 해당 기간에 주문 없음, When GET, Then 200 + 빈 목록 + +**쿼리 파라미터:** + +| 파라미터 | 기본값 | 설명 | +|---------|--------|------| +| startAt | (필수) | 조회 시작일 (KST) | +| endAt | (필수) | 조회 종료일 (KST) | +| page | 0 | 페이지 번호 | +| size | 20 | 페이지당 주문 수 | + +#### US-18: 단일 주문 상세 조회 + +**As a** 로그인한 회원 +**I want to** 주문 상세 정보(스냅샷 포함)를 조회 +**So that** 주문한 상품의 당시 정보를 확인할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 본인 주문, When GET /api/v1/orders/{orderId}, Then 200 + 주문 상세 (스냅샷 포함) +- [ ] AC2: Given 타인 주문, When GET, Then 403 +- [ ] AC3: Given 존재하지 않는 orderId, When GET, Then 404 + +#### US-19: 어드민 주문 목록/상세 조회 + +**As a** 어드민 +**I want to** 전체 주문 목록과 상세 정보를 조회 +**So that** 주문 현황을 파악하고 관리할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given LDAP 인증, When GET /api-admin/v1/orders?page=0&size=20, Then 200 + 페이지네이션된 전체 주문 목록 +- [ ] AC2: Given LDAP 인증 + orderId, When GET /api-admin/v1/orders/{orderId}, Then 200 + 주문 상세 + +#### US-20: 어드민 주문 취소 + +**As a** 어드민 +**I want to** 주문을 취소하고 재고를 복원 +**So that** 잘못된 주문이나 고객 요청을 처리할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given LDAP 인증 + ORDERED 상태 주문, When PATCH /api-admin/v1/orders/{orderId}/cancel, Then 200 + 상태 CANCELLED + 재고 복원 +- [ ] AC2: Given 이미 CANCELLED 상태, When PATCH, Then 409 Conflict +- [ ] AC3: Given 존재하지 않는 orderId, When PATCH, Then 404 + +## 4. 비기능 요구사항 (전체) + +- **인증**: 고객 → X-Loopers-LoginId + X-Loopers-LoginPw 헤더, 어드민 → X-Loopers-Ldap: loopers.admin 헤더 +- **응답 형식**: 표준 ApiResponse (meta: {result, errorCode, message}, data: T) +- **Soft Delete**: BaseEntity의 deletedAt 필드 기반 +- **비밀번호**: BCrypt 암호화 저장 +- **아키텍처**: interfaces → application(Facade) → domain(Service) ← infrastructure(Repository) +- **테스트**: 단위 + 통합 + E2E, MySQL TestContainer 사용 + +## 5. Scope-out (명시적 제외) + +- **결제 시스템** - 주문 완료 = 결제 완료로 간주. 추후 추가 개발 +- **쿠폰/프로모션** - 프로젝트 명세에서 "나중에 고려"로 명시 +- **포인트 충전** - 프로젝트 명세에서 명시적으로 제외 +- **추천/랭킹 알고리즘** - 데이터 축적이 먼저. 좋아요/주문 데이터 구조만 확보 +- **리뷰/별점** - 명세에 없음 +- **배송/물류** - 명세에 없음 +- **동시성/멱등성 고도화** - 기본 기능 개발 후 해결 (프로젝트 명세에 명시) +- **JWT/세션 인증** - 헤더 기반 식별로 확정 +- **Rate Limiting** - 인프라 레벨, MVP 범위 밖 + +## 6. 미결정 사항 + +없음 (모두 해소됨) + +**해소 이력:** +- phone: 010-XXXX-XXXX 형식 (하이픈 포함 13자) → 확정 +- category: 별도 테이블 (Seed 데이터, 조회 전용) → 확정 +- startAt/endAt: KST (Asia/Seoul) 기준, 날짜만 받고 KST 00:00~23:59로 처리 → 확정 +- 좋아요 목록: page/size 페이지네이션 추가 → 확정 + +## 7. 용어 사전 + +| 용어 | 정의 | +|------|------| +| loginId | 멤버 로그인 식별자 (영문 소문자 + 숫자) | +| Brand | 브랜드 (애견용품 제조/판매 브랜드) | +| Category | 상품 카테고리 (Seed 데이터 기반 조회 전용 테이블) | +| Product | 상품 (개별 애견용품) | +| Like | 좋아요 (멤버의 상품 관심 표시) | +| Order | 주문 (하나 이상의 상품을 포함하는 구매 요청) | +| OrderItem | 주문 항목 (주문 내 개별 상품 + 수량 + 스냅샷) | +| Snapshot | 스냅샷 (주문 시점의 상품 정보 사본 — 주문 번호, 상품명, 가격, 브랜드명) | +| likeCount | 상품별 좋아요 수 (Product 테이블 필드) | +| LDAP | 어드민 인증 헤더 (X-Loopers-Ldap: loopers.admin) | +| Soft Delete | deletedAt 필드 기반 논리적 삭제 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..e1d2e313d --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,212 @@ +# 시퀀스 다이어그램 + +## 주문 요청 (POST /api/v1/orders) + +주문 생성은 이 시스템에서 가장 복잡한 로직이다. 상품 활성 상태 확인 → 재고 확인 → 재고 차감 → 스냅샷 생성 → 주문 저장이 원자적으로 처리되는지, 실패 시 전체 롤백이 보장되는지 검증한다. + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant OC as OrderController + participant OF as OrderFacade + participant PS as ProductService + participant OS as OrderService + + C->>OC: POST /api/v1/orders (items) + OC->>OF: 주문 생성 요청 + + loop 각 OrderItem에 대해 + OF->>PS: 활성 상품 조회 및 재고 검증 + PS-->>OF: 상품 정보 반환 + end + + alt 삭제된 상품 포함 + OF-->>OC: 400 Bad Request + OC-->>C: 400 (삭제된 상품) + else 재고 부족 + OF-->>OC: 400 Bad Request + OC-->>C: 400 (재고 부족) + else 모든 검증 통과 + note over OF: 스냅샷 생성 (주문 번호, 상품명, 가격, 브랜드명) + + loop 각 OrderItem에 대해 + OF->>PS: 재고 차감 요청 + PS-->>OF: 차감 완료 + end + + OF->>OS: 주문 생성 (스냅샷 포함) + OS-->>OF: 주문 생성 완료 + + OF-->>OC: 주문 정보 반환 + OC-->>C: 201 Created + end +``` + +### 핵심 포인트 +- **전체 실패 정책**: 여러 상품 중 하나라도 문제가 있으면 전체 주문이 실패한다 (부분 성공 없음). +- **스냅샷 시점**: Facade에서 검증 완료된 상품 정보로 스냅샷을 생성한 후, OrderService에 전달. + +### 설계 리스크 +- **크로스 도메인 원자성**: 재고 차감과 주문 저장이 별도 트랜잭션이므로, 주문 저장 실패 시 재고 복원 보상 로직 필요. +- **재고 동시성**: Facade의 읽기 검증과 재고 차감 사이에 갭이 존재. `WHERE stock >= quantity` 조건으로 해결 가능. + +--- + +## 주문 취소 (PATCH /orders/{orderId}/cancel) + +주문 취소는 고객/어드민 모두 가능하되 권한이 다르다. 상태 변경과 재고 복원이 처리되는지, 이미 취소된 주문에 대한 처리를 검증한다. + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant OC as OrderController + participant OF as OrderFacade + participant OS as OrderService + participant PS as ProductService + + C->>OC: PATCH /orders/{orderId}/cancel + OC->>OF: 주문 취소 요청 + + OF->>OS: 주문 + 주문항목 조회 + OS-->>OF: 주문 정보 반환 + + note over OF: 권한 확인 (고객: 본인만, 어드민: 모두) + + OF->>OS: 주문 취소 처리 + + alt 이미 CANCELLED + OS-->>OF: 409 Conflict + OF-->>OC: 409 Conflict + OC-->>C: 409 (이미 취소됨) + else ORDERED 상태 + OS-->>OF: 취소 완료 + + loop 각 OrderItem에 대해 + OF->>PS: 재고 복원 요청 + PS-->>OF: 복원 완료 + end + + OF-->>OC: 취소 완료 + OC-->>C: 200 OK + end +``` + +### 핵심 포인트 +- **권한 분기**: Facade에서 권한을 확인한 후 (고객: 본인만, 어드민: 모두), 취소 로직을 진행. + +### 설계 리스크 +- **삭제된 상품의 재고 복원**: 주문 후 상품이 Soft Delete된 경우, 취소 시 재고를 복원해야 하는지 정책 결정 필요. 현재는 복원하는 것으로 가정. + +--- + +## 좋아요 등록 (POST /api/v1/products/{productId}/likes) + +좋아요 등록은 Like 저장과 Product의 likeCount 업데이트가 일관성 있게 처리되는지 검증한다. 중복 좋아요 방지와 삭제된 상품 차단 로직을 확인한다. + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant LC as LikeController + participant LF as LikeFacade + participant PS as ProductService + participant LS as LikeService + + C->>LC: POST /products/{productId}/likes + LC->>LF: 좋아요 등록 요청 + + LF->>PS: 활성 상품 확인 + PS-->>LF: 상품 정보 반환 + + alt 삭제된 상품 + LF-->>LC: 400 Bad Request + LC-->>C: 400 (삭제된 상품) + else 활성 상품 + LF->>LS: 좋아요 등록 + LS-->>LF: 등록 결과 + + alt 이미 좋아요 + LF-->>LC: 409 Conflict + LC-->>C: 409 (이미 좋아요) + else 좋아요 가능 + LF->>PS: 좋아요 수 증가 + PS-->>LF: 증가 완료 + + LF-->>LC: 좋아요 등록 완료 + LC-->>C: 201 Created + end + end +``` + +### 핵심 포인트 +- **이중 보호**: 애플리케이션 레벨 중복 체크 + DB Unique 제약조건으로 중복 방지. + +### 설계 리스크 +- **크로스 도메인 원자성**: 좋아요 저장과 좋아요 수 증가가 별도 트랜잭션. 좋아요 수 증가 실패 시 좋아요 삭제 보상 로직 필요. +- **likeCount 경합**: 인기 상품에 좋아요가 몰릴 경우 likeCount UPDATE에서 락 경합 발생 가능. 기본 기능 개발 후 고도화 단계에서 해결. + +--- + +## 브랜드 삭제 (DELETE /api-admin/v1/brands/{brandId}) + +브랜드 삭제는 소프트 삭제 정책으로 수행되며, 브랜드 삭제 시 해당 브랜드의 상품도 함께 soft-delete 처리되는지 확인한다. + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant BC as BrandAdminController + participant BS as BrandApplicationService + + + C->>BC: DELETE /api-admin/v1/brands/{brandId} + note over BC: LDAP 인증 확인 + BC->>BS: 브랜드 삭제 요청 + + BS->>BS: 브랜드 조회 + BS-->>BC: 브랜드 정보 (없으면 404) + + BS->>BS: 브랜드 + 연관 상품 삭제 + BS-->>BC: 브랜드 + 연관 상품 삭제 완료 + + BC-->>C: 200 OK +``` + +### 핵심 포인트 +- **삭제 정책**: BrandService가 브랜드 삭제와 연관 상품 soft-delete를 단일 트랜잭션 안에서 수행한다. + +### 설계 리스크 +- **확인-삭제 갭 제거**: 사전 존재성 검사 분기를 제거하고, 삭제 플로우 내부에서 일괄 soft-delete를 수행해 경쟁 조건을 줄인다. + +--- + +## 상품 목록 조회 (GET /api/v1/products) + +동적 필터(브랜드), 정렬(최신순/가격순/좋아요순), 페이지네이션이 결합된 읽기 전용 쿼리다. Facade 없이 처리되는 조회 패턴과, Soft Delete된 상품이 고객/어드민 API에서 다르게 처리되는 로직을 검증한다. + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant PC as ProductController + participant PS as ProductService + + C->>PC: GET /api/v1/products?brandId=1&sort=price_asc&page=0&size=20 + PC->>PS: 활성 상품 목록 조회 (필터 + 정렬 + 페이지네이션) + PS-->>PC: 상품 목록 반환 + PC-->>C: 200 OK + 페이지네이션된 상품 목록 + + alt 존재하지 않는 brandId 필터 + PC-->>C: 200 OK + 빈 목록 + end +``` + +### 핵심 포인트 +- **Facade 불필요**: 읽기 전용 조회이므로 Controller → Service로 직접 흐른다. +- **동적 쿼리**: brandId는 선택적 필터, sort는 3가지 정렬 기준(latest, price_asc, likes_desc), page/size로 페이지네이션 처리. +- **Soft Delete 분기**: 고객 API는 삭제 상품 제외. 어드민 API(`/api-admin/v1/products`)는 deletedAt 포함하여 전체 노출. + +### 설계 리스크 +- **정렬 성능**: likes_desc 정렬 시 likeCount 컬럼에 인덱스가 없으면 대량 데이터에서 성능 저하 가능. 인덱스 추가로 해결. diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..ee55e3432 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,91 @@ +# 클래스 다이어그램 + +핵심 도메인 엔티티의 책임 분배와 관계 방향을 검증한다. 비즈니스 로직이 Service가 아닌 엔티티에 적절히 위치하는지, OrderItem의 스냅샷 패턴이 Product와 올바르게 분리되었는지, 도메인 간 의존 방향이 올바른지 확인한다. + +## 클래스 다이어그램 + +```mermaid +classDiagram + class Brand { + -String name + -String description + -String imageUrl + +updateInfo(description, imageUrl) Brand + } + + class Category { + -String name + } + + class Product { + -String name + -int price + -int stock + -String description + -int likeCount + +decreaseStock(quantity) + +increaseStock(quantity) + +increaseLikeCount() + +decreaseLikeCount() + +isActive() boolean + } + + class Like { + -LocalDateTime likedAt + } + + class Member { + -MemberId loginId + -Password password + -Name name + -Email email + -BirthDate birthDate + -Phone phone + +getMaskedName() String + +changePassword(currentPw, newPw) Member + } + + class Order { + -String orderNumber + -LocalDateTime orderDate + -OrderStatus status + -int totalAmount + +cancel() + } + + class OrderItem { + -int quantity + -String snapshotProductName + -int snapshotPrice + -String snapshotBrandName + } + + class OrderStatus { + <> + ORDERED + CANCELLED + } + + Brand "1" -- "*" Product : 보유한다 + Category "1" -- "*" Product : 분류한다 + Product "1" -- "*" Like : 받는다 + Product "1" ..> "*" OrderItem : 스냅샷으로 캡처 + Member "1" -- "*" Like : 좋아요한다 + Member "1" -- "*" Order : 주문한다 + Order "1" *-- "*" OrderItem : 포함한다 + Order -- OrderStatus +``` + +## 핵심 포인트 + +- **불변 도메인 객체**: 기존 Member가 record 기반 불변 객체 + Value Object(MemberId, Password, Name, Email, BirthDate) 패턴으로 구현되어 있다. 새 도메인도 동일 패턴 적용. +- **엔티티에 비즈니스 로직 배치**: Product.decreaseStock(), Order.cancel() 등 상태 변경 로직이 Service가 아닌 엔티티 자체에 위치하여 빈약한 도메인 방지. +- **스냅샷 분리 (점선)**: OrderItem은 Product의 런타임 참조를 갖지 않는다. 주문 시점의 상품명/가격/브랜드명을 스냅샷 필드로 복사하여 Product 변경/삭제에 영향받지 않음. +- **Like = 조인 엔티티**: Member-Product 간 N:M 관계를 Like 엔티티로 풀어낸다. DB에서 (memberId + productId) Unique 제약조건으로 중복 방지. +- **Brand.name 불변**: updateInfo()는 description, imageUrl만 수정 가능. name은 생성 시 확정. +- **Category는 Seed 데이터**: 비즈니스 메서드 없음. 조회 전용 참조 테이블. + +## 설계 리스크 + +- **Product 상태 변경의 동시성**: decreaseStock(), increaseLikeCount() 등이 동시 호출될 때 경합 발생 가능. 엔티티 레벨에서는 검증만 수행하고, 동시성 제어는 인프라(DB 락/조건부 UPDATE)에서 해결. +- **OrderItem 스냅샷 필드 확장**: 현재 상품명/가격/브랜드명 3개. 향후 카테고리, 이미지 등 스냅샷 대상이 늘어나면 OrderItem이 비대해질 수 있다. 현재 요구사항에서는 3개로 충분. diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..06532752e --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,73 @@ +# ERD (논리적 관계) + +## ERD + +```mermaid +erDiagram + USER { + string loginId + string password + string name + string email + date birthDate + string phone + } + BRAND { + string name + string description + string imageUrl + } + CATEGORY { + string name + } + PRODUCT { + string name + int price + int stock + string description + int likeCount + } + ORDER { + string orderNumber + datetime orderDate + string status + int totalAmount + } + ORDER_ITEM { + int quantity + string snapshotProductName + int snapshotPrice + string snapshotBrandName + } + + USER ||--o{ ORDER : "주문한다" + LIKE { + datetime likedAt + } + + USER ||--o{ LIKE : "좋아요한다" + PRODUCT ||--o{ LIKE : "받는다" + ORDER ||--|{ ORDER_ITEM : "포함한다" + BRAND ||--o{ PRODUCT : "보유한다" + CATEGORY ||--o{ PRODUCT : "분류한다" +``` + +## 핵심 포인트 +- **Like = Member-Product N:M 조인 엔티티**: Member와 Product 간 N:M 관계를 Like 엔티티로 풀어냈다. DB에서 (memberId + productId) Unique 제약조건으로 중복 방지. Like 자체에 비즈니스 속성은 없으므로 속성 블록을 생략했다. +- **OrderItem 스냅샷 비정규화**: OrderItem은 Product와 FK 관계가 없다. 주문 시점의 상품명/가격/브랜드명을 자체 필드에 복사하여, Product 변경/삭제에 영향받지 않는 독립적 데이터로 존재한다. ERD에서 Product-OrderItem 간 관계선이 없는 이유. +- **Order-OrderItem 컴포지션**: Order 삭제 시 OrderItem도 함께 삭제되는 강한 소유 관계. 최소 1개 이상의 OrderItem이 필요하다 (`||--|{`). + +## 엔티티 삭제 전략 +| 엔티티 | 삭제 전략 | 이유 | +|--------|-----------|------| +| USER | Soft Delete | 주문/좋아요 이력 참조 및 감사 추적 필요 | +| BRAND | Soft Delete | 상품 이력 참조 정합성 유지 필요 | +| CATEGORY | Soft Delete | 상품 분류 이력 및 참조 무결성 유지 필요 | +| PRODUCT | Soft Delete | 주문 스냅샷 및 좋아요 이력과의 추적성 유지 | +| ORDER | Soft Delete | 감사/정산 목적 보관 필요 | +| ORDER_ITEM | Soft Delete | 주문 감사 추적 일관성 유지 | +| LIKE | Hard Delete | 사용자 취소 가능한 임시 관계 데이터 | + +## 설계 리스크 +- **likeCount 비정규화**: Product.likeCount는 Like 테이블의 COUNT와 동기화되어야 한다. 좋아요 등록/취소 시 별도 트랜잭션에서 업데이트하므로, 일시적 불일치 가능성 있음. 선택지: (A) 현재 설계 유지 + 주기적 보정 배치 (B) likeCount 제거하고 매번 COUNT 쿼리. +- **OrderItem-Product 참조 부재**: 스냅샷 패턴으로 런타임 참조가 없으므로, "이 주문 항목이 어떤 상품이었는지" 역추적이 스냅샷 필드(상품명)에 의존한다. 선택지: (A) 현재 설계 유지 (B) productId를 참조용으로 보관 (FK 아닌 논리적 참조). diff --git a/docs/design/05-adr.md b/docs/design/05-adr.md new file mode 100644 index 000000000..a746cb099 --- /dev/null +++ b/docs/design/05-adr.md @@ -0,0 +1,22 @@ +# ADR (Architecture Decision Records) + +### ADR-01: 크로스 도메인 트랜잭션 — Service 개별 트랜잭션 + Facade 보상 + +- **맥락**: 주문 생성 시 재고 차감(ProductService)과 주문 저장(OrderService)이 서로 다른 도메인에 걸친다. +- **결정**: 각 Service가 자기 도메인 내에서 개별 @Transactional. Facade에는 트랜잭션 없이 보상 로직으로 원자성 확보. +- **근거**: Facade에 트랜잭션을 두면 도메인 경계가 무너지고 트랜잭션 범위가 비대화됨. +- **기각된 대안**: Facade @Transactional로 단일 트랜잭션 → 도메인 독립성 훼손. + +### ADR-02: OrderItem 스냅샷 패턴 — Product 런타임 참조 없음 + +- **맥락**: 주문 후 상품 가격/이름이 변경되면 주문 이력이 오염된다. +- **결정**: OrderItem에 주문 시점의 상품명/가격/브랜드명을 복사. Product와 FK 관계 없음. +- **근거**: Product 변경/삭제가 주문 이력에 영향을 주면 안 됨. +- **트레이드오프**: 역추적이 스냅샷 필드에 의존. 필요 시 productId를 참조용으로 추가 가능. + +### ADR-03: likeCount 비정규화 + +- **맥락**: 상품 목록 정렬(likes_desc)에 좋아요 수가 필요. 매번 COUNT 쿼리 vs Product 필드 캐싱. +- **결정**: Product.likeCount 필드에 비정규화. 좋아요 등록/취소 시 UPDATE. +- **근거**: 상품 목록 조회마다 JOIN + COUNT는 성능 부담. +- **트레이드오프**: Like 테이블과 일시적 불일치 가능. 보정 배치로 해결 가능. diff --git a/docs/requirements.md b/docs/requirements.md deleted file mode 100644 index eddcd9047..000000000 --- a/docs/requirements.md +++ /dev/null @@ -1,88 +0,0 @@ -# User API 요구사항 명세서 - -## 1. 회원가입 - -### API -- **POST** `/api/v1/users` -- **Status**: 201 Created - -### 요청 정보 -| 필드 | 타입 | 필수 | 검증 규칙 | -|------|------|------|----------| -| loginId | String | O | 4~20자, 영문소문자+숫자만 | -| password | String | O | 8~16자, 대/소문자+숫자+특수문자 필수, 생년월일 미포함 | -| name | String | O | 2~10자, 한글만 | -| email | String | O | 이메일 형식 | -| birthDate | String | O | yyyy-MM-dd, 과거 날짜만 | - -### 비즈니스 규칙 -- 이미 가입된 로그인 ID로는 가입 불가 (409 Conflict) -- 비밀번호는 BCrypt로 암호화하여 저장 - -### 비밀번호 규칙 -1. 8~16자의 영문 대소문자, 숫자, 특수문자만 가능 -2. 대문자, 소문자, 숫자, 특수문자 각 1개 이상 필수 -3. 생년월일은 비밀번호 내에 포함될 수 없음 (yyMMdd, MMdd, ddMM 형식) - ---- - -## 2. 내 정보 조회 - -### API -- **GET** `/api/v1/users/me` -- **Status**: 200 OK - -### 인증 헤더 -| 헤더 | 설명 | -|------|------| -| X-Loopers-LoginId | 로그인 ID | -| X-Loopers-LoginPw | 비밀번호 | - -### 응답 정보 -| 필드 | 타입 | 설명 | -|------|------|------| -| loginId | String | 로그인 ID | -| name | String | 이름 (마지막 글자 마스킹) | -| email | String | 이메일 | -| birthDate | String | 생년월일 (yyyy-MM-dd) | - -### 비즈니스 규칙 -- 이름은 마지막 글자를 `*`로 마스킹하여 반환 - - 예: "홍길동" → "홍길*" -- 인증 실패 시 401 Unauthorized - ---- - -## 3. 비밀번호 수정 - -### API -- **PATCH** `/api/v1/users/me/password` -- **Status**: 200 OK - -### 인증 헤더 -| 헤더 | 설명 | -|------|------| -| X-Loopers-LoginId | 로그인 ID | -| X-Loopers-LoginPw | 현재 비밀번호 | - -### 요청 정보 -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| currentPassword | String | O | 현재 비밀번호 (헤더와 동일) | -| newPassword | String | O | 새 비밀번호 | - -### 비즈니스 규칙 -- 비밀번호 규칙 준수 (회원가입과 동일) -- 현재 비밀번호와 동일한 비밀번호로 변경 불가 (400 Bad Request) -- 인증 실패 시 401 Unauthorized - ---- - -## 공통 에러 응답 - -| Status | ErrorType | 설명 | -|--------|-----------|------| -| 400 | BAD_REQUEST | 유효성 검증 실패 | -| 401 | UNAUTHORIZED | 인증 실패 | -| 409 | CONFLICT | 중복 리소스 | -| 500 | INTERNAL_ERROR | 서버 오류 | diff --git a/docs/user-domain.md b/docs/user-domain.md deleted file mode 100644 index 1cb947be3..000000000 --- a/docs/user-domain.md +++ /dev/null @@ -1,251 +0,0 @@ -# User 도메인 구현 문서 - -## 개요 - -사용자 회원가입, 인증, 비밀번호 변경 기능을 구현했습니다. -Layered Architecture + DDD 기반으로 설계했습니다. - ---- - -## API 명세 - -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | /api/v1/users | 회원가입 | -| GET | /api/v1/users/me | 내 정보 조회 | -| PATCH | /api/v1/users/me/password | 비밀번호 변경 | - ---- - -## 아키텍처 - -```mermaid -graph TB - subgraph Interface["Interface Layer"] - UC[UserController] - AR[AuthUserArgumentResolver] - end - - subgraph Application["Application Layer"] - UF[UserFacade] - end - - subgraph Domain["Domain Layer"] - US[UserService] - UAS[UserAuthService] - U[User] - PE[PasswordEncoder] - end - - subgraph Infrastructure["Infrastructure Layer"] - UE[UserEntity] - URI[UserRepositoryImpl] - BCE[BCryptPasswordEncoder] - end - - UC --> UF - UC --> US - UF --> UAS - UF --> US - US --> UR[UserRepository] - UAS --> UR - URI -.->|implements| UR - BCE -.->|implements| PE -``` - ---- - -## 시퀀스 다이어그램 - -### 1. 회원가입 - -```mermaid -sequenceDiagram - actor Client - participant UC as UserController - participant US as UserService - participant UR as UserRepository - participant DB as Database - - Client->>UC: POST /api/v1/users - UC->>US: register(command) - US->>US: VO 검증 (Password, Email 등) - US->>UR: existsByUserId() - UR->>DB: SELECT - DB-->>UR: false - US->>US: 비밀번호 암호화 (BCrypt) - US->>UR: save(user) - UR->>DB: INSERT - DB-->>UR: OK - US-->>UC: User - UC-->>Client: 201 Created -``` - -### 2. 비밀번호 변경 - -```mermaid -sequenceDiagram - actor Client - participant UC as UserController - participant UF as UserFacade - participant UAS as UserAuthService - participant US as UserService - participant UR as UserRepository - participant DB as Database - - Client->>UC: PATCH /api/v1/users/me/password - Note right of Client: Header: Authorization - UC->>UF: changePassword(request) - UF->>UAS: authenticate(command) - UAS->>UR: findByUserId() - UR->>DB: SELECT - DB-->>UR: UserEntity - UAS->>UAS: 비밀번호 검증 - UAS-->>UF: User - - UF->>US: changePassword(command) - US->>US: 신규 비밀번호 검증 - Note right of US: - 8~16자
- 대/소문자, 숫자, 특수문자
- 생년월일 미포함
- 기존 비밀번호와 다름 - US->>US: BCrypt 암호화 - US->>UR: save(user) - UR->>DB: UPDATE - DB-->>UR: OK - US-->>UF: void - UF-->>UC: void - UC-->>Client: 200 OK -``` - ---- - -## 도메인 모델 - -```mermaid -classDiagram - class User { - <> - -UserId id - -Password password - -Name name - -Email email - -BirthDate birthDate - +getMaskedName() String - } - - class UserId { - <> - -String value - 검증: 4~20자, 영문소문자+숫자 - } - - class Password { - <> - -String value - 검증: 8~16자 - 대/소문자, 숫자, 특수문자 필수 - +containsDate(date) boolean - +isEncoded() boolean - +ofEncoded(String) Password - } - - class Email { - <> - -String value - 검증: 이메일 형식 - } - - class Name { - <> - -String value - 검증: 2~10자, 한글만 - } - - class BirthDate { - <> - -LocalDate value - 검증: 과거 날짜만 - } - - User *-- UserId - User *-- Password - User *-- Name - User *-- Email - User *-- BirthDate -``` - ---- - -## ERD - -```mermaid -erDiagram - users { - BIGINT id PK "AUTO_INCREMENT" - VARCHAR(20) user_id UK - VARCHAR(255) password - VARCHAR(10) name - VARCHAR(255) email - DATE birth_date - DATETIME created_at - DATETIME updated_at - } -``` - ---- - -## 비밀번호 정책 - -| 규칙 | 조건 | -|------|------| -| 길이 | 8~16자 | -| 대문자 | 1개 이상 필수 | -| 소문자 | 1개 이상 필수 | -| 숫자 | 1개 이상 필수 | -| 특수문자 | 1개 이상 필수 | -| 생년월일 | 포함 불가 (yyMMdd, MMdd, ddMM) | -| 변경 시 | 기존 비밀번호와 동일 불가 | - ---- - -## 패키지 구조 - -``` -com.loopers -├── application/user -│ ├── UserFacade.java -│ ├── UserFacadeDto.java -│ └── command/ -│ ├── AuthenticateCommand.java -│ ├── ChangePasswordCommand.java -│ └── RegisterCommand.java -├── domain/user -│ ├── User.java -│ ├── UserService.java -│ ├── UserAuthService.java -│ ├── UserRepository.java -│ ├── PasswordEncoder.java -│ ├── vo/ -│ │ ├── UserId.java -│ │ ├── Password.java -│ │ ├── Email.java -│ │ ├── Name.java -│ │ └── BirthDate.java -│ └── exception/ -│ └── UserValidationException.java -├── infrastructure/user -│ ├── UserEntity.java -│ ├── UserJpaRepository.java -│ └── UserRepositoryImpl.java -└── interfaces/api/user - ├── UserController.java - ├── UserApiSpec.java - └── UserDto.java -``` - ---- - -## 설계 포인트 - -1. **Value Object 분리**: Password, Email 등 검증 로직을 VO에 캡슐화 -2. **도메인 서비스 분리**: UserService(CUD), UserAuthService(인증) 책임 분리 -3. **Facade 패턴**: 비밀번호 변경 시 인증 + 변경을 조합 -4. **Infrastructure 분리**: JPA Entity와 Domain 모델 분리 (toDomain/from 변환)