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/requirements-analysis/references/diagram-guide.md b/.claude/skills/requirements-analysis/references/diagram-guide.md index 5c9aa440b..88c4d47bd 100644 --- a/.claude/skills/requirements-analysis/references/diagram-guide.md +++ b/.claude/skills/requirements-analysis/references/diagram-guide.md @@ -12,13 +12,12 @@ docs/design/ 04-erd.md ``` -## 3단계 원칙 +## 2단계 원칙 모든 다이어그램은 반드시 이 순서를 따른다: -1. **이유**: 왜 이 다이어그램이 필요한지, 무엇을 검증하려는지 설명한다 -2. **다이어그램**: Mermaid 문법으로 작성한다 -3. **해석**: "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다 +1. **다이어그램**: Mermaid 문법으로 작성한다 +2. **해석**: "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다 ## 생성 순서 @@ -32,10 +31,27 @@ docs/design/ - 전체 로직을 하나의 다이어그램에 그리지 않는다 - 각 시퀀스는 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, SQL 쿼리 상세, Repository participant. Facade ↔ Service 레벨까지만 표현한다. +- **표기 금지**: 시퀀스 다이어그램에서는 `@Transactional`을 note/메시지/participant 어디에도 표기하지 않는다. SQL 쿼리 상세, Repository participant도 금지한다. Facade ↔ Service 레벨까지만 표현한다. - **자주 겪는 실수**: (1) 세부 흐름 과다로 시퀀스 복잡화 (2) Service만 호출, 도메인 객체 메시지 없음 (3) 추상 수준이 낮아 구현과 괴리 → 유지보수 불가 - 파일명: `docs/design/02-sequence-diagrams.md` (모든 시퀀스를 하나의 파일에 작성) - 최소 2개 이상의 시퀀스 다이어그램을 작성한다 @@ -47,9 +63,6 @@ docs/design/ ## [기능명 1] -### 왜 이 다이어그램이 필요한가 -[이 시퀀스로 검증하려는 것: 책임 분리, 호출 순서, 트랜잭션 경계 등] - ### 시퀀스 다이어그램 \```mermaid @@ -90,9 +103,6 @@ sequenceDiagram ```markdown # 클래스 다이어그램 -## 왜 이 다이어그램이 필요한가 -[검증하려는 것: 도메인 책임, 의존 방향, 응집도] - ## 클래스 다이어그램 \```mermaid @@ -125,15 +135,27 @@ classDiagram 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 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..8520745bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# 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로 직접 연결한다 +- Controller 조합/오케스트레이션 로직 금지 (여러 도메인 데이터 결합/매핑은 Facade 또는 Service로 이동) +- Application Service -> 자기 도메인 Repository + Domain Service only +- Application Service 간 직접 호출 금지 (크로스 도메인 협력은 Facade에서 조정) +- Domain Service는 순수 비즈니스 규칙만 담당 (저장/외부 I/O/트랜잭션 금지) +- `@Transactional`은 Application Service에만 위치 (Facade/Domain Service 금지) +- Application/Domain Service의 private 메서드 금지 +- Application/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 cf0852556..61ea1505c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ - 불필요한 private 함수 지양, 객체지향적 코드 작성 - /application/ 내부 모든 클래스 private 함수 금지 - domain 서비스 private 함수 금지 +- Controller 조합/오케스트레이션 로직 금지 (여러 도메인 데이터 결합/매핑은 Facade 또는 Service로 이동) +- Application/Domain Service 내부 메서드 간 직접 호출 금지 - unused import 제거 - 성능 최적화 - 모든 테스트 케이스가 통과해야 함 @@ -25,12 +27,15 @@ - **Facade → Service만 호출**. Repository를 직접 접근하지 않는다. - **Service → 자기 도메인 Repository만 접근**. 다른 도메인의 Service나 Repository를 호출하지 않는다. - **Service 간 직접 호출 금지**. 크로스 도메인 협력은 반드시 Facade를 통해 이루어진다. -- **트랜잭션 위치**: `@Transactional`은 Domain Service에만 위치한다. Facade에는 절대 `@Transactional`을 두지 않는다. 크로스 도메인 쓰기 시 각 Service가 자기 도메인 내에서 개별 트랜잭션을 관리하고, 실패 시 Facade에서 보상 로직을 처리한다. +- **Controller 조합/오케스트레이션 로직 금지**. Controller는 요청/응답 변환만 수행한다. +- **Application/Domain Service private 메서드 금지**. +- **Application/Domain Service 내부 메서드 간 직접 호출 금지**. +- **트랜잭션 위치**: `@Transactional`은 Domain Service 및 Application Service에만 위치한다. Facade에는 절대 `@Transactional`을 두지 않는다. 크로스 도메인 쓰기 시 각 Service가 자기 도메인 내에서 개별 트랜잭션을 관리하고, 실패 시 Facade에서 보상 로직을 처리한다. ## 비즈니스 규칙 - 결제 시스템 없음. 주문 완료 = 결제 완료로 취급한다. - 주문 시점의 상품 정보를 스냅샷으로 저장한다 (주문 후 상품 변경에 영향받지 않도록). -- 어드민 인증은 X-ROOPERS-LDAP 헤더 기반으로 처리한다. +- 어드민 인증은 X-Loopers-Ldap 헤더 기반으로 처리한다. - 포인트 충전 기능은 범위 외 (Scope-out). ## 주의사항 @@ -54,4 +59,4 @@ 2. null-safety, thread-safety 고려 3. 테스트 가능한 구조로 설계 4. 기존 코드 패턴 분석 후 일관성 유지 -5. 코드 뎁스는 1로 제한 \ No newline at end of file +5. 코드 뎁스는 1로 제한 diff --git a/OPENCODE.md b/OPENCODE.md new file mode 100644 index 000000000..b9ffb7030 --- /dev/null +++ b/OPENCODE.md @@ -0,0 +1,9 @@ +# OpenCode Project Rules + +- Controller 조합/오케스트레이션 로직 금지 (Controller는 요청/응답 변환만 수행) +- 여러 도메인 데이터 결합/매핑은 Facade 또는 Service에서 처리 +- Application Service private 메서드 금지 +- Domain Service private 메서드 금지 +- Application/Domain Service 내부 메서드 간 직접 호출 금지 + +- 상세 규칙은 `AGENTS.md`, `docs/ai-rules/coding-style.md`를 우선 참조 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java new file mode 100644 index 000000000..9c550ab38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -0,0 +1,24 @@ +package com.loopers.application.brand; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.application.product.ProductApplicationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BrandAdminFacade { + + private final BrandApplicationService brandApplicationService; + private final ProductApplicationService productApplicationService; + private final LikeApplicationService likeApplicationService; + + public void delete(Long brandId) { + List productIds = productApplicationService.findActiveProductIdsByBrandId(brandId); + likeApplicationService.deleteByProductIds(productIds); + productApplicationService.deleteSoftByBrandId(brandId); + brandApplicationService.delete(brandId); + } +} 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..f7de34090 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -0,0 +1,82 @@ +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@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(readOnly = true) + public Page list(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Map findNamesByIds(Collection brandIds) { + return brandIds.stream() + .distinct() + .collect(Collectors.toMap( + brandId -> brandId, + brandId -> brandRepository.findById(brandId) + .map(brand -> brand.name().value()) + .orElse(null) + )); + } + + @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.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/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..9e4020f06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,65 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +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.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class LikeApplicationService { + + private final LikeRepository likeRepository; + + public LikeApplicationService(LikeRepository likeRepository) { + this.likeRepository = likeRepository; + } + + @Transactional + public void register(String memberId, Long productId) { + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + } + + try { + likeRepository.save(new Like(memberId, productId)); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + } + } + + @Transactional + public void cancel(String memberId, Long productId) { + if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "좋아요한 상품이 아닙니다."); + } + likeRepository.deleteByMemberIdAndProductId(memberId, productId); + } + + @Transactional(readOnly = true) + public void assertLiked(String memberId, Long productId) { + if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "좋아요한 상품이 아닙니다."); + } + } + + @Transactional(readOnly = true) + public Page getMyLikeProductIds(String memberId, Pageable pageable) { + return likeRepository.findByMemberId(memberId, pageable) + .map(Like::productId); + } + + @Transactional + public void deleteByProductIds(List productIds) { + if (productIds.isEmpty()) { + return; + } + likeRepository.deleteByProductIds(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..c7691ecba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,37 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductLikeAplicationService; +import com.loopers.domain.member.Member; +import com.loopers.domain.product.Product; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LikeFacade { + + private final LikeApplicationService likeApplicationService; + private final ProductLikeAplicationService productLikeAplicationService; + + public void register(Long productId, Member member) { + String memberId = member.id().value(); + productLikeAplicationService.validateLikeable(productId); + likeApplicationService.register(memberId, productId); + productLikeAplicationService.increaseLikeCount(productId); + } + + public void cancel(Long productId, Member member) { + String memberId = member.id().value(); + likeApplicationService.assertLiked(memberId, productId); + productLikeAplicationService.validateCancelable(productId); + likeApplicationService.cancel(memberId, productId); + productLikeAplicationService.decreaseLikeCount(productId); + } + + public Page getMyLikes(String memberId, Pageable pageable) { + Page likedProductIds = likeApplicationService.getMyLikeProductIds(memberId, pageable); + return productLikeAplicationService.getMyLikedProducts(likedProductIds, pageable); + } +} 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..76702d529 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java @@ -0,0 +1,42 @@ +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, "회원을 찾을 수 없습니다.")); + } + + public Long findDbIdByMember(Member member) { + return memberRepository.findDbIdByMemberId(member.id()) + .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..9368e2813 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,77 @@ +package com.loopers.application.order; + +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.application.order.query.OrderListByUserRequest; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderItem; +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.List; +import java.util.Locale; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrderApplicationService { + + private final OrderRepository orderRepository; + + @Transactional + public Order create(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + + String orderNumber = UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(Locale.ROOT); + Order order = new Order(userId, orderNumber, items); + return orderRepository.save(order); + } + + @Transactional + public Order cancel(OrderAccessRequest request) { + Order order = orderRepository.findById(request.orderId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!request.isAdmin() && !order.isOwner(request.userId())) { + throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 취소할 수 없습니다."); + } + + Order cancelled = order.cancel(); // 이미 취소된 경우 409 CONFLICT 던짐 + return orderRepository.save(cancelled); + } + + @Transactional(readOnly = true) + public Order getById(OrderAccessRequest request) { + Order order = orderRepository.findById(request.orderId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!request.isAdmin() && !order.isOwner(request.userId())) { + throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 조회할 수 없습니다."); + } + + return order; + } + + @Transactional(readOnly = true) + public Page listByUser(OrderListByUserRequest request) { + ZoneId kst = ZoneId.of("Asia/Seoul"); + ZonedDateTime startDateTime = request.startAt().atStartOfDay(kst); + ZonedDateTime endDateTime = request.endAt().atTime(23, 59, 59).atZone(kst); + return orderRepository.findByUserId(request.userId(), startDateTime, endDateTime, request.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/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..c6e94e7f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,60 @@ +package com.loopers.application.order; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.application.product.ProductStockApplicationService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final OrderApplicationService orderApplicationService; + private final ProductStockApplicationService productStockApplicationService; + private final BrandApplicationService brandApplicationService; + + public Order create(CreateOrderCommand command) { + if (command.items() == null || command.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + + List reservedProducts = + productStockApplicationService.reserveForOrder(command.items()); + + Map brandNames = reservedProducts.stream() + .map(ProductStockApplicationService.ReservedProduct::brandId) + .distinct() + .collect(Collectors.toMap( + brandId -> brandId, + brandId -> brandApplicationService.findById(brandId).name().value() + )); + + List orderItems = reservedProducts.stream() + .map(reserved -> new OrderItem( + reserved.productId(), + reserved.quantity(), + reserved.productName(), + reserved.productPrice(), + brandNames.get(reserved.brandId()) + )) + .toList(); + + return orderApplicationService.create(command.userId(), orderItems); + } + + public Order cancel(OrderAccessRequest request) { + Order cancelled = orderApplicationService.cancel(request); + productStockApplicationService.restoreForOrder(cancelled.items()); + return cancelled; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java new file mode 100644 index 000000000..c2e748bb1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java @@ -0,0 +1,18 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderQueryApplicationService { + + private final OrderRepository orderRepository; + + @Transactional(readOnly = true) + public boolean existsOrderItemByProductId(Long productId) { + return orderRepository.existsOrderItemByProductId(productId); + } +} 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/order/query/OrderAccessRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java new file mode 100644 index 000000000..b3e391ee4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java @@ -0,0 +1,8 @@ +package com.loopers.application.order.query; + +public record OrderAccessRequest( + Long orderId, + Long userId, + boolean isAdmin +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java new file mode 100644 index 000000000..1ebb2cc23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java @@ -0,0 +1,13 @@ +package com.loopers.application.order.query; + +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; + +public record OrderListByUserRequest( + Long userId, + LocalDate startAt, + LocalDate endAt, + Pageable pageable +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java new file mode 100644 index 000000000..753e6c234 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -0,0 +1,22 @@ +package com.loopers.application.product; + +import com.loopers.application.order.OrderQueryApplicationService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductAdminFacade { + + private final ProductApplicationService productApplicationService; + private final OrderQueryApplicationService orderQueryApplicationService; + + public void delete(Long productId) { + if (orderQueryApplicationService.existsOrderItemByProductId(productId)) { + throw new CoreException(ErrorType.CONFLICT, "주문 이력이 있는 상품은 삭제할 수 없습니다."); + } + productApplicationService.deleteSoft(productId); + } +} 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..eb545e9f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -0,0 +1,135 @@ +package com.loopers.application.product; + +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.application.product.command.UpdateProductCommand; +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.domain.product.query.ProductListCriteria; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; + +@Service +@RequiredArgsConstructor +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); + } + + @Transactional(readOnly = true) + public Page list(ProductListCriteria criteria) { + return productRepository.findAll(criteria.brandId(), criteria.toPageable()); + } + + @Transactional(readOnly = true) + public Product getIncludingDeleted(Long productId) { + return productRepository.findByIdIncludingDeleted(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page listIncludingDeleted(Long brandId, Pageable pageable) { + return productRepository.findAllIncludingDeleted(brandId, pageable); + } + + @Transactional(readOnly = true) + public Page listIncludingDeleted(ProductListCriteria criteria) { + return productRepository.findAllIncludingDeleted(criteria.brandId(), criteria.toPageable()); + } + + @Transactional(readOnly = true) + public java.util.List findActiveProductIdsByBrandId(Long brandId) { + return productRepository.findIdsByBrandId(brandId); + } + + @Transactional + public void deleteSoftByBrandId(Long brandId) { + productRepository.softDeleteByBrandId(brandId); + } + + @Transactional + public Product update(Long productId, UpdateProductCommand command) { + Product existing = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + if (command.brandId() != null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 수정할 수 없습니다."); + } + + if (categoryRepository.findById(command.categoryId()).isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않거나 삭제된 카테고리입니다."); + } + + Product updated = new Product( + existing.id(), + command.name(), + command.price(), + command.stock(), + command.description(), + command.categoryId(), + existing.brandId(), + existing.likeCount(), + existing.deletedAt() + ); + return productRepository.save(updated); + } + + @Transactional + public void deleteSoft(Long productId) { + Product existing = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + Product deleted = new Product( + existing.id(), + existing.name(), + existing.price(), + existing.stock(), + existing.description(), + existing.categoryId(), + existing.brandId(), + existing.likeCount(), + ZonedDateTime.now() + ); + productRepository.save(deleted); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java new file mode 100644 index 000000000..467cf7f83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java @@ -0,0 +1,61 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +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; + +@Service +@RequiredArgsConstructor +public class ProductLikeAplicationService { + + private final ProductRepository productRepository; + + @Transactional(readOnly = true) + public void validateLikeable(Long productId) { + if (productRepository.findById(productId).isPresent()) { + return; + } + if (productRepository.findByIdIncludingDeleted(productId).isPresent()) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품은 좋아요할 수 없습니다."); + } + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + + @Transactional(readOnly = true) + public void validateCancelable(Long productId) { + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional + public void increaseLikeCount(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + productRepository.save(product.increaseLikeCount()); + } + + @Transactional + public void decreaseLikeCount(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + productRepository.save(product.decreaseLikeCount()); + } + + @Transactional(readOnly = true) + public Page getMyLikedProducts(Page likedProductIds, Pageable pageable) { + List products = likedProductIds.getContent().stream() + .map(productRepository::findById) + .flatMap(java.util.Optional::stream) + .toList(); + return new PageImpl<>(products, pageable, products.size()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java new file mode 100644 index 000000000..8f38eed6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java @@ -0,0 +1,93 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.product.view.ProductListView; +import com.loopers.application.product.view.ProductView; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.query.ProductListCriteria; +import com.loopers.domain.product.query.ProductListQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ProductQueryFacade { + + private final ProductApplicationService productApplicationService; + private final BrandApplicationService brandApplicationService; + private final ProductService productService; + + public ProductView get(Long productId) { + Product product = productApplicationService.get(productId); + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + return ProductView.from(product, brandNames.get(product.brandId())); + } + + public ProductView getIncludingDeleted(Long productId) { + Product product = productApplicationService.getIncludingDeleted(productId); + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + return ProductView.from(product, brandNames.get(product.brandId())); + } + + public ProductListView list(ProductListQuery query) { + ProductListCriteria criteria = productService.toCriteria(query); + Page products = productApplicationService.list(criteria); + Map brandNames = brandApplicationService.findNamesByIds( + products.getContent().stream().map(Product::brandId).toList() + ); + List items = products.getContent().stream() + .map(product -> ProductView.from(product, brandNames.get(product.brandId()))) + .toList(); + return new ProductListView( + items, + products.getNumber(), + products.getSize(), + products.getTotalElements(), + products.getTotalPages() + ); + } + + public ProductListView listIncludingDeleted(ProductListQuery query) { + ProductListCriteria criteria = productService.toCriteria(query); + Page products = productApplicationService.listIncludingDeleted(criteria); + Map brandNames = brandApplicationService.findNamesByIds( + products.getContent().stream().map(Product::brandId).toList() + ); + List items = products.getContent().stream() + .map(product -> ProductView.from(product, brandNames.get(product.brandId()))) + .toList(); + return new ProductListView( + items, + products.getNumber(), + products.getSize(), + products.getTotalElements(), + products.getTotalPages() + ); + } + + public ProductView toView(Product product) { + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + return ProductView.from(product, brandNames.get(product.brandId())); + } + + public ProductListView toListView(Page products) { + Map brandNames = brandApplicationService.findNamesByIds( + products.getContent().stream().map(Product::brandId).toList() + ); + List items = products.getContent().stream() + .map(product -> ProductView.from(product, brandNames.get(product.brandId()))) + .toList(); + return new ProductListView( + items, + products.getNumber(), + products.getSize(), + products.getTotalElements(), + products.getTotalPages() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java new file mode 100644 index 000000000..6d0344f0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java @@ -0,0 +1,77 @@ +package com.loopers.application.product; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProductStockApplicationService { + + private final ProductRepository productRepository; + + @Transactional + public List reserveForOrder(List items) { + List productIds = items.stream() + .map(CreateOrderCommand.OrderItemCommand::productId) + .toList(); + + List products = productRepository.findAllByIdInWithLock(productIds); + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + + for (Product product : products) { + if (product.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품이 포함되어 있습니다."); + } + } + + Map productMap = products.stream() + .collect(Collectors.toMap(Product::id, product -> product)); + + return items.stream() + .map(item -> { + Product product = productMap.get(item.productId()); + Product updated = product.decreaseStock(item.quantity()); + productRepository.save(updated); + return new ReservedProduct( + product.id(), + item.quantity(), + product.name(), + product.price(), + product.brandId() + ); + }) + .toList(); + } + + @Transactional + public void restoreForOrder(List items) { + for (OrderItem item : items) { + productRepository.findById(item.productId()).ifPresent(product -> { + Product restored = product.increaseStock(item.quantity()); + productRepository.save(restored); + }); + } + } + + public record ReservedProduct( + Long productId, + int quantity, + String productName, + int productPrice, + Long brandId + ) { + } +} 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/product/command/UpdateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java new file mode 100644 index 000000000..30026add4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java @@ -0,0 +1,11 @@ +package com.loopers.application.product.command; + +public record UpdateProductCommand( + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductListView.java b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductListView.java new file mode 100644 index 000000000..73cf70b60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductListView.java @@ -0,0 +1,12 @@ +package com.loopers.application.product.view; + +import java.util.List; + +public record ProductListView( + List items, + int page, + int size, + long totalElements, + int totalPages +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java new file mode 100644 index 000000000..84447bf72 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java @@ -0,0 +1,33 @@ +package com.loopers.application.product.view; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductView( + Long id, + String name, + Integer price, + Integer stock, + String description, + Long categoryId, + Long brandId, + String brandName, + Integer likeCount, + ZonedDateTime deletedAt +) { + public static ProductView from(Product product, String brandName) { + return new ProductView( + product.id(), + product.name(), + product.price(), + product.stock(), + product.description(), + product.categoryId(), + product.brandId(), + brandName, + product.likeCount(), + product.deletedAt() + ); + } +} 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..9e00dcdc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandName; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + + Optional findById(Long id); + + Page findAll(Pageable pageable); + + boolean existsById(Long id); + + boolean existsByName(BrandName name); + + void delete(Brand brand); +} 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..42afe8483 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java @@ -0,0 +1,28 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +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 CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + if (name.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 " + MAX_LENGTH + "자를 초과할 수 없습니다."); + } + if (!VALID_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름에는 영문, 한글, 숫자, '-', '_', '.'만 사용할 수 있습니다."); + } + } +} 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/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/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..2f74c318c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface LikeRepository { + Like save(Like like); + + boolean existsByMemberIdAndProductId(String memberId, Long productId); + + void deleteByMemberIdAndProductId(String memberId, Long productId); + + void deleteByProductIds(List productIds); + + 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..beadd32a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,59 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 CoreException(ErrorType.BAD_REQUEST, 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/user/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java similarity index 75% 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..483fa5504 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,7 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -18,22 +19,22 @@ public record BirthDate(LocalDate value) { public BirthDate { if (value == null) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_NULL_OR_EMPTY); } if (value.isAfter(LocalDate.now())) { - throw new UserValidationException(ERROR_FUTURE_DATE); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_FUTURE_DATE); } } public static BirthDate of(String dateString) { if (dateString == null || dateString.isBlank()) { - throw new UserValidationException(ERROR_NULL_OR_EMPTY); + throw new CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, 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 72% 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..c12a1bb97 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,7 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.regex.Pattern; @@ -30,36 +31,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 CoreException(ErrorType.BAD_REQUEST, ERROR_NULL_OR_EMPTY); } if (email.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_MAX_LENGTH); } int atIndex = email.indexOf('@'); if (atIndex == -1) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, ERROR_LOCAL_PART_MAX_LENGTH); } if (localPart.startsWith(".")) { - throw new UserValidationException(ERROR_STARTS_WITH_DOT); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_STARTS_WITH_DOT); } if (localPart.endsWith(".")) { - throw new UserValidationException(ERROR_ENDS_WITH_DOT); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_ENDS_WITH_DOT); } if (CONSECUTIVE_DOTS_PATTERN.matcher(email).find()) { - throw new UserValidationException(ERROR_CONSECUTIVE_DOTS); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_CONSECUTIVE_DOTS); } if (INVALID_LOCAL_CHARS_PATTERN.matcher(localPart).find()) { - throw new UserValidationException(ERROR_INVALID_LOCAL_CHARS); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_INVALID_LOCAL_CHARS); } if (!EMAIL_PATTERN.matcher(email).matches()) { - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new CoreException(ErrorType.BAD_REQUEST, 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 55% 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..2c1938339 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,13 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 +26,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 CoreException(ErrorType.BAD_REQUEST, ERROR_NULL_OR_EMPTY); } - if (userId.length() < MIN_LENGTH) { - throw new UserValidationException(ERROR_MIN_LENGTH); + if (memberId.length() < MIN_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_MIN_LENGTH); } - if (userId.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + if (memberId.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, 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 78% 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..9342759b2 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,7 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.regex.Pattern; @@ -28,24 +29,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 CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, 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 CoreException(ErrorType.BAD_REQUEST, ERROR_ENGLISH_MAX_LENGTH); } if (isEnglishOnly(name)) { return; } - throw new UserValidationException(ERROR_INVALID_FORMAT); + throw new CoreException(ErrorType.BAD_REQUEST, 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 72% 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..51895e0e0 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,7 @@ -package com.loopers.domain.user.vo; +package com.loopers.domain.member.vo; -import com.loopers.domain.user.exception.UserValidationException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -25,6 +26,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 +40,9 @@ private Password(String value, EncodedMarker marker) { } public static Password ofEncoded(String encodedPassword) { + if (!isEncodedFormat(encodedPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_NOT_ENCODED); + } return new Password(encodedPassword, EncodedMarker.INSTANCE); } @@ -47,22 +52,22 @@ public String value() { private void validate(String password) { if (password == null || password.length() < MIN_LENGTH) { - throw new UserValidationException(ERROR_MIN_LENGTH); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_MIN_LENGTH); } if (password.length() > MAX_LENGTH) { - throw new UserValidationException(ERROR_MAX_LENGTH); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_MAX_LENGTH); } if (!UPPERCASE_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_UPPERCASE_REQUIRED); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_UPPERCASE_REQUIRED); } if (!LOWERCASE_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_LOWERCASE_REQUIRED); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_LOWERCASE_REQUIRED); } if (!DIGIT_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_DIGIT_REQUIRED); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_DIGIT_REQUIRED); } if (!SPECIAL_CHAR_PATTERN.matcher(password).find()) { - throw new UserValidationException(ERROR_SPECIAL_CHAR_REQUIRED); + throw new CoreException(ErrorType.BAD_REQUEST, ERROR_SPECIAL_CHAR_REQUIRED); } } @@ -77,7 +82,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..3f91b46ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Phone.java @@ -0,0 +1,28 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +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 CoreException(ErrorType.BAD_REQUEST, ERROR_NULL_OR_EMPTY); + } + if (!PHONE_PATTERN.matcher(phone).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, 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..f57fdbab4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,28 @@ +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); + + Page findAllIncludingDeleted(Long brandId, Pageable pageable); + + List findIdsByBrandId(Long brandId); + + void softDeleteByBrandId(Long brandId); + + void delete(Product product); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..43059a152 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,15 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.query.ProductListCriteria; +import com.loopers.domain.product.query.ProductListQuery; +import com.loopers.domain.product.query.ProductSortOption; +import org.springframework.stereotype.Service; + +@Service +public class ProductService { + + public ProductListCriteria toCriteria(ProductListQuery query) { + ProductSortOption sortOption = ProductSortOption.fromApiValue(query.sort()); + return ProductListCriteria.of(query.brandId(), query.page(), query.size(), sortOption); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java new file mode 100644 index 000000000..2d65d9da6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java @@ -0,0 +1,43 @@ +package com.loopers.domain.product.query; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +public record ProductListCriteria( + Long brandId, + int page, + int size, + ProductSortOption sortOption +) { + public static final int DEFAULT_PAGE = 0; + public static final int DEFAULT_SIZE = 20; + public static final int MAX_SIZE = 100; + + public ProductListCriteria { + if (page < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상이어야 합니다."); + } + if (size < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 1 이상이어야 합니다."); + } + if (size > MAX_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 " + MAX_SIZE + "을(를) 초과할 수 없습니다."); + } + if (sortOption == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "정렬 옵션은 필수입니다."); + } + } + + public static ProductListCriteria of(Long brandId, Integer page, Integer size, ProductSortOption sortOption) { + int resolvedPage = page == null ? DEFAULT_PAGE : page; + int resolvedSize = size == null ? DEFAULT_SIZE : size; + ProductSortOption resolvedSortOption = sortOption == null ? ProductSortOption.defaultOption() : sortOption; + return new ProductListCriteria(brandId, resolvedPage, resolvedSize, resolvedSortOption); + } + + public Pageable toPageable() { + return PageRequest.of(page, size, sortOption.toSort()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java new file mode 100644 index 000000000..0859d5b28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java @@ -0,0 +1,9 @@ +package com.loopers.domain.product.query; + +public record ProductListQuery( + Long brandId, + String sort, + Integer page, + Integer size +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductSortOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductSortOption.java new file mode 100644 index 000000000..20dace6da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductSortOption.java @@ -0,0 +1,36 @@ +package com.loopers.domain.product.query; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.Sort; + +public enum ProductSortOption { + LATEST, + PRICE_ASC, + LIKES_DESC; + + public static ProductSortOption defaultOption() { + return LATEST; + } + + public static ProductSortOption fromApiValue(String value) { + if (value == null || value.isBlank()) { + return defaultOption(); + } + + return switch (value) { + case "latest" -> LATEST; + case "price_asc" -> PRICE_ASC; + case "likes_desc" -> LIKES_DESC; + default -> throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 정렬 기준입니다: %s".formatted(value)); + }; + } + + public Sort toSort() { + return switch (this) { + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + }; + } +} 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..fa0d9a283 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.brand; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByName(String name); + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + boolean existsByName(String name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..caed4ee21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,53 @@ +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.findByIdAndDeletedAtIsNull(id) + .map(BrandEntity::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAllByDeletedAtIsNull(pageable) + .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 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/category/CategorySeedInitializer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java new file mode 100644 index 000000000..a5a16973c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.category; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Profile("!test") +@RequiredArgsConstructor +public class CategorySeedInitializer implements ApplicationRunner { + + private static final List DEFAULT_CATEGORIES = List.of( + "사료", + "간식", + "장난감", + "위생", + "산책" + ); + + private final CategoryJpaRepository categoryJpaRepository; + + @Override + public void run(ApplicationArguments args) { + if (categoryJpaRepository.count() > 0) { + return; + } + + List seedEntities = DEFAULT_CATEGORIES.stream() + .map(CategoryEntity::new) + .toList(); + categoryJpaRepository.saveAll(seedEntities); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/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..60c81c1bf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndProductId(String memberId, Long productId); + + void deleteByMemberIdAndProductId(String memberId, Long productId); + + void deleteByProductIdIn(List productIds); + + 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..5f3af03cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,44 @@ +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; + +import java.util.List; + +@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 void deleteByProductIds(List productIds) { + likeJpaRepository.deleteByProductIdIn(productIds); + } + + @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..bd4134869 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,89 @@ +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.categoryId = product.categoryId(); + 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..eadc7a2bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,35 @@ +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + @Query("SELECT p.id FROM ProductEntity p WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + List findIdsByBrandIdAndDeletedAtIsNull(@Param("brandId") Long brandId); + + @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 softDeleteByBrandId(@Param("brandId") Long brandId); + + @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..c24315072 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,86 @@ +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); + if (product.deletedAt() != null) { + entity.delete(); + } + 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 Page findAllIncludingDeleted(Long brandId, Pageable pageable) { + if (brandId == null) { + return productJpaRepository.findAll(pageable).map(ProductEntity::toDomain); + } + return productJpaRepository.findAllByBrandId(brandId, pageable).map(ProductEntity::toDomain); + } + + @Override + public List findIdsByBrandId(Long brandId) { + return productJpaRepository.findIdsByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public void softDeleteByBrandId(Long brandId) { + productJpaRepository.softDeleteByBrandId(brandId); + } + + @Override + public List findAllByIdInWithLock(List ids) { + return productJpaRepository.findAllByIdInWithLock(ids) + .stream().map(ProductEntity::toDomain).toList(); + } + + @Override + public void delete(Product product) { + productJpaRepository.findById(product.id()) + .ifPresent(ProductEntity::delete); + } +} 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..ff959cfc6 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,10 @@ 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.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,20 +23,15 @@ 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); return failureResponse(e.getErrorType()); } - @ExceptionHandler - public ResponseEntity> handle(UserValidationException e) { - log.warn("UserValidationException : {}", e.getMessage(), e); - return failureResponse(ErrorType.BAD_REQUEST); - } - @ExceptionHandler public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { String name = e.getName(); 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..e56b17f36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.brand.BrandAdminFacade; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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; + private final BrandAdminFacade brandAdminFacade; + + @GetMapping + public ApiResponse listBrands( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page brands = brandApplicationService.list(pageable); + return ApiResponse.success(BrandDto.BrandListResponse.from(brands)); + } + + @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) { + brandAdminFacade.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..3c3c102bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java @@ -0,0 +1,41 @@ +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 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.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 final CategoryApplicationService categoryApplicationService; + + @GetMapping + public ApiResponse listCategories( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page categories = categoryApplicationService.list(pageable); + return ApiResponse.success(CategoryDto.CategoryListResponse.from(categories)); + } + + @GetMapping("/{categoryId}") + public ApiResponse getCategory( + @PathVariable Long categoryId + ) { + Category category = categoryApplicationService.findById(categoryId); + return ApiResponse.success(CategoryDto.CategoryResponse.from(category)); + } +} 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..d9334e80c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.order.OrderDto; +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.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 final OrderApplicationService orderApplicationService; + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse listOrders( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page orders = orderApplicationService.listAll(pageable); + return ApiResponse.success(OrderDto.OrderListResponse.from(orders)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @PathVariable Long orderId + ) { + Order order = orderApplicationService.getById(new OrderAccessRequest(orderId, null, true)); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @PatchMapping("/{orderId}/cancel") + public ApiResponse cancelOrder( + @PathVariable Long orderId + ) { + Order order = orderFacade.cancel(new OrderAccessRequest(orderId, null, true)); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java new file mode 100644 index 000000000..d4ec6f728 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java @@ -0,0 +1,67 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.product.ProductApplicationService; +import com.loopers.application.product.ProductAdminFacade; +import com.loopers.application.product.ProductQueryFacade; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.query.ProductListQuery; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/products") +public class AdminProductController { + + private final ProductApplicationService productApplicationService; + private final ProductAdminFacade productAdminFacade; + private final ProductQueryFacade productQueryFacade; + + @GetMapping + public ApiResponse listProducts(ProductListQuery query) { + return ApiResponse.success(ProductDto.ProductListResponse.from( + productQueryFacade.listIncludingDeleted(query) + )); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.getIncludingDeleted(productId))); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct( + @Valid @RequestBody ProductDto.CreateProductRequest request + ) { + Product created = productApplicationService.create(request.toCommand()); + return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.toView(created))); + } + + @PutMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductDto.UpdateProductRequest request + ) { + Product updated = productApplicationService.update(productId, request.toCommand()); + return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.toView(updated))); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct(@PathVariable Long productId) { + productAdminFacade.delete(productId); + return ApiResponse.success(); + } +} 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..a3361d9cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java @@ -0,0 +1,95 @@ +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; +import org.springframework.data.domain.Page; + +import java.util.List; + +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(); + } + } + + public record BrandListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static BrandListResponse from(Page pageData) { + List items = pageData.getContent().stream() + .map(BrandResponse::from) + .toList(); + + return new BrandListResponse( + items, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } +} 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/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/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..c33439720 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.application.order.OrderFacade; +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 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.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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import com.loopers.application.order.query.OrderAccessRequest; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderApplicationService orderApplicationService; + private final OrderFacade orderFacade; + private final MemberAuthenticationService memberAuthenticationService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createOrder( + @AuthMember Member member, + @Valid @RequestBody OrderDto.CreateOrderRequest request + ) { + Long userId = memberAuthenticationService.findDbIdByMember(member); + Order order = orderFacade.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 = memberAuthenticationService.findDbIdByMember(member); + Order order = orderFacade.cancel(new OrderAccessRequest(orderId, userId, false)); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + Long userId = memberAuthenticationService.findDbIdByMember(member); + Order order = orderApplicationService.getById(new OrderAccessRequest(orderId, userId, false)); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping + public ApiResponse listOrders( + @AuthMember Member member, + @Valid OrderDto.ListOrdersRequest request + ) { + Long userId = memberAuthenticationService.findDbIdByMember(member); + Page orders = orderApplicationService.listByUser(request.toQuery(userId)); + return ApiResponse.success(OrderDto.OrderListResponse.from(orders)); + } +} 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..a488a8a94 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java @@ -0,0 +1,122 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.application.order.query.OrderListByUserRequest; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import jakarta.validation.Valid; +import org.springframework.data.domain.PageRequest; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +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() + ); + } + } + + public record ListOrdersRequest( + @NotNull(message = "시작일은 필수입니다") + @DateTimeFormat(pattern = "yyyyMMdd") + LocalDate startAt, + @NotNull(message = "종료일은 필수입니다") + @DateTimeFormat(pattern = "yyyyMMdd") + LocalDate endAt, + Integer page, + Integer size + ) { + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + + public OrderListByUserRequest toQuery(Long userId) { + int resolvedPage = page == null ? DEFAULT_PAGE : page; + int resolvedSize = size == null ? DEFAULT_SIZE : size; + Pageable pageable = PageRequest.of(resolvedPage, resolvedSize); + return new OrderListByUserRequest(userId, startAt, endAt, pageable); + } + } +} 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..8efa2589b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductQueryFacade; +import com.loopers.domain.product.Product; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthMember; +import com.loopers.domain.member.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +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") +@RequiredArgsConstructor +public class LikeController { + + private final LikeFacade likeFacade; + private final ProductQueryFacade productQueryFacade; + + @PostMapping("/products/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse registerLike(@PathVariable Long productId, @AuthMember Member member) { + likeFacade.register(productId, member); + return ApiResponse.success(); + } + + @DeleteMapping("/products/{productId}/likes") + public ApiResponse cancelLike(@PathVariable Long productId, @AuthMember Member member) { + likeFacade.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 + ) { + Page likedProducts = likeFacade.getMyLikes(member.id().value(), PageRequest.of(page, size)); + return ApiResponse.success(ProductDto.ProductListResponse.from(productQueryFacade.toListView(likedProducts))); + } +} 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..16f62abe9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductApplicationService; +import com.loopers.application.product.ProductQueryFacade; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.query.ProductListQuery; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.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; + private final ProductQueryFacade productQueryFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct( + @Valid @RequestBody ProductDto.CreateProductRequest request + ) { + Product created = productApplicationService.create(request.toCommand()); + return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.toView(created))); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.get(productId))); + } + + @GetMapping + public ApiResponse getProducts(ProductListQuery query) { + return ApiResponse.success(ProductDto.ProductListResponse.from( + productQueryFacade.list(query) + )); + } +} 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..f8b9345a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -0,0 +1,141 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.application.product.command.UpdateProductCommand; +import com.loopers.application.product.view.ProductListView; +import com.loopers.application.product.view.ProductView; +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.time.ZonedDateTime; +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, + BrandInfo brand, + Integer likeCount, + ZonedDateTime deletedAt + ) { + public static ProductResponse from(Product product) { + return from(product, null); + } + + public static ProductResponse from(ProductView productView) { + return new ProductResponse( + productView.id(), + productView.name(), + productView.price(), + productView.stock(), + productView.description(), + productView.categoryId(), + productView.brandId(), + new BrandInfo(productView.brandId(), productView.brandName()), + productView.likeCount(), + productView.deletedAt() + ); + } + + public static ProductResponse from(Product product, String brandName) { + return new ProductResponse( + product.id(), + product.name(), + product.price(), + product.stock(), + product.description(), + product.categoryId(), + product.brandId(), + new BrandInfo(product.brandId(), brandName), + product.likeCount(), + product.deletedAt() + ); + } + } + + public record BrandInfo( + Long id, + String name + ) { + } + + public record UpdateProductRequest( + @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, + Long brandId + ) { + public UpdateProductCommand toCommand() { + return new UpdateProductCommand(name, price, stock, description, categoryId, brandId); + } + } + + public record ProductListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page pageData, List items) { + return new ProductListResponse( + items, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + + public static ProductListResponse from(Page pageData) { + List items = pageData.getContent().stream() + .map(ProductResponse::from) + .toList(); + return from(pageData, items); + } + + public static ProductListResponse from(ProductListView view) { + List items = view.items().stream() + .map(ProductResponse::from) + .toList(); + return new ProductListResponse(items, view.page(), view.size(), view.totalElements(), view.totalPages()); + } + } +} 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/AdminLdapInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminLdapInterceptor.java new file mode 100644 index 000000000..eae0664ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminLdapInterceptor.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminLdapInterceptor implements HandlerInterceptor { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_ADMIN = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String ldap = request.getHeader(HEADER_LDAP); + + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 없습니다."); + } + if (!LDAP_ADMIN.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "관리자 권한이 없습니다."); + } + return true; + } +} 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..27424195e 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,9 +1,11 @@ package com.loopers.interfaces.config; -import com.loopers.interfaces.auth.AuthUserArgumentResolver; +import com.loopers.interfaces.auth.AuthMemberArgumentResolver; +import com.loopers.interfaces.auth.AdminLdapInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -12,10 +14,17 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - private final AuthUserArgumentResolver authUserArgumentResolver; + private final AuthMemberArgumentResolver authMemberArgumentResolver; + private final AdminLdapInterceptor adminLdapInterceptor; @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(authUserArgumentResolver); + resolvers.add(authMemberArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminLdapInterceptor) + .addPathPatterns("/api-admin/v1/**"); } } 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..6bc6c1197 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java @@ -0,0 +1,153 @@ +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.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@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("브랜드 삭제 성공") + void deleteSuccess() { + Brand target = new Brand(1L, new BrandName("퍼피박스"), "설명", "https://example.com/brand.png"); + + when(brandRepository.findById(1L)).thenReturn(Optional.of(target)); + doNothing().when(brandRepository).delete(target); + + brandApplicationService.delete(1L); + + verify(brandRepository).findById(1L); + verify(brandRepository).delete(target); + } + + @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()).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..f6e7c81bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -0,0 +1,151 @@ +package com.loopers.application.order; + +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +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.anyLong; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderApplicationServiceTest { + + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private OrderApplicationService orderApplicationService; + private static final OrderItem SAMPLE_ORDER_ITEM = new OrderItem(1L, 2, "강아지 사료", 10000, "퍼피박스"); + + @Nested + @DisplayName("주문 생성") + class Create { + + @Test + @DisplayName("유효한 주문 항목으로 주문 생성 성공") + void createOrderSuccess() { + List items = List.of(SAMPLE_ORDER_ITEM); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderApplicationService.create(1L, items); + + assertThat(result.status()).isEqualTo(OrderStatus.ORDERED); + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.items()).hasSize(1); + } + + @Test + @DisplayName("주문 항목이 비어있으면 400 예외가 발생한다") + void emptyItemsFails() { + List items = List.of(); + + assertThatThrownBy(() -> orderApplicationService.create(1L, items)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @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(new OrderAccessRequest(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(new OrderAccessRequest(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(new OrderAccessRequest(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(new OrderAccessRequest(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(new OrderAccessRequest(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..b7ee5af9f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.brand; + +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..54bc55aeb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.brand.vo; + +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.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(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("브랜드 이름 길이 제한 - 50자 초과") + void brandNameExceeds50Chars() { + // given + String name = "a".repeat(51); + + // when & then + assertThatThrownBy(() -> new BrandName(name)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @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/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/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..7f7030df3 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.support.error.CoreException; +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,15 @@ 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(CoreException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -66,15 +68,15 @@ 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(CoreException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -82,15 +84,15 @@ 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(CoreException.class) .hasMessage("비밀번호에 생년월일을 포함할 수 없습니다"); } @@ -98,15 +100,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 +137,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 +157,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 +177,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 81% 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..d579a908a 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.support.error.CoreException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -40,16 +40,14 @@ void validBirthDateLocalDate() { @ValueSource(strings = {" "}) void emptyOrNullBirthDate(String dateString) { // when & then - assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> BirthDate.of(dateString)).isInstanceOf(CoreException.class); } @Test @DisplayName("생년월일 형식 오류 - null LocalDate") void nullLocalDate() { // when & then - assertThatThrownBy(() -> new BirthDate(null)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new BirthDate(null)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -57,8 +55,7 @@ void nullLocalDate() { @ValueSource(strings = {"1999-01-15", "99/01/15", "15011999", "abcdefgh"}) void invalidFormat(String dateString) { // when & then - assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> BirthDate.of(dateString)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -66,8 +63,7 @@ void invalidFormat(String dateString) { @ValueSource(strings = {"19990230", "19991301", "19990132"}) void invalidDate(String dateString) { // when & then - assertThatThrownBy(() -> BirthDate.of(dateString)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> BirthDate.of(dateString)).isInstanceOf(CoreException.class); } @Test @@ -77,8 +73,7 @@ void futureDate() { LocalDate futureDate = LocalDate.now().plusDays(1); // when & then - assertThatThrownBy(() -> new BirthDate(futureDate)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new BirthDate(futureDate)).isInstanceOf(CoreException.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 63% 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..c3148344f 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.support.error.CoreException; 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,82 +40,73 @@ void validEmail(String email) { @DisplayName("이메일 형식 오류 - '@' 누락") void emailWithoutAtSign() { // when & then - assertThatThrownBy(() -> new Email("userexample.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("memberexample.com")).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - 도메인 부분 누락") void emailWithoutDomain() { // when & then - assertThatThrownBy(() -> new Email("user@")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@")).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - 로컬 부분 누락") void emailWithoutLocalPart() { // when & then - assertThatThrownBy(() -> new Email("@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("@example.com")).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - 도메인에 '.' 누락") void emailWithoutDotInDomain() { // when & then - assertThatThrownBy(() -> new Email("user@examplecom")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@examplecom")).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - TLD 누락 (마지막 '.' 뒤에 아무것도 없음)") void emailWithoutTld() { // when & then - assertThatThrownBy(() -> new Email("user@example.")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@example.")).isInstanceOf(CoreException.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); + assertThatThrownBy(() -> new Email(email)).isInstanceOf(CoreException.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(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - 로컬 부분이 '.'으로 시작") void emailStartsWithDot() { // when & then - assertThatThrownBy(() -> new Email(".user@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email(".member@example.com")).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - 로컬 부분이 '.'으로 끝남") void emailLocalPartEndsWithDot() { // when & then - assertThatThrownBy(() -> new Email("user.@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member.@example.com")).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -124,16 +115,14 @@ void emailLocalPartEndsWithDot() { @ValueSource(strings = {" "}) void emptyOrNullEmail(String email) { // when & then - assertThatThrownBy(() -> new Email(email)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email(email)).isInstanceOf(CoreException.class); } @Test @DisplayName("이메일 형식 오류 - '@'가 여러 개") void emailWithMultipleAtSigns() { // when & then - assertThatThrownBy(() -> new Email("user@@example.com")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email("member@@example.com")).isInstanceOf(CoreException.class); } @Test @@ -143,8 +132,7 @@ void emailLocalPartExceeds64Chars() { String longLocalPart = "a".repeat(65) + "@example.com"; // when & then - assertThatThrownBy(() -> new Email(longLocalPart)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email(longLocalPart)).isInstanceOf(CoreException.class); } @Test @@ -154,8 +142,7 @@ void emailExceeds254Chars() { String longEmail = "a".repeat(64) + "@" + "b".repeat(189) + ".com"; // when & then - assertThatThrownBy(() -> new Email(longEmail)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Email(longEmail)).isInstanceOf(CoreException.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..f4e702d6d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -0,0 +1,138 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +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(CoreException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 4자 미만") + @ValueSource(strings = {"a", "ab", "abc"}) + void memberIdTooShort(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("아이디 형식 오류 - 20자 초과") + void memberIdTooLong() { + // given + String longMemberId = "a".repeat(21); + + // when & then + assertThatThrownBy(() -> new MemberId(longMemberId)).isInstanceOf(CoreException.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(CoreException.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(CoreException.class); + } + + @ParameterizedTest + @DisplayName("아이디 형식 오류 - 한글 포함") + @ValueSource(strings = {"member한글", "한글member", "유저이름"}) + void memberIdWithKorean(String memberId) { + // when & then + assertThatThrownBy(() -> new MemberId(memberId)).isInstanceOf(CoreException.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(CoreException.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 79% 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..64abb33ea 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.support.error.CoreException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -40,8 +40,7 @@ void validEnglishName(String name) { @ValueSource(strings = {" "}) void emptyOrNullName(String name) { // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -49,8 +48,7 @@ void emptyOrNullName(String name) { @ValueSource(strings = {"홍길동John", "John홍길동", "김John", "Hong길동"}) void mixedName(String name) { // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -58,8 +56,7 @@ void mixedName(String name) { @ValueSource(strings = {"홍길동1", "John123", "123"}) void nameWithNumbers(String name) { // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -67,8 +64,7 @@ void nameWithNumbers(String name) { @ValueSource(strings = {"홍길동!", "John@", "김-철수", "Jane.Doe"}) void nameWithSpecialChars(String name) { // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.class); } @ParameterizedTest @@ -76,8 +72,7 @@ void nameWithSpecialChars(String name) { @ValueSource(strings = {"홍 길동", "John Doe", "Kim Cheol Su"}) void nameWithSpaces(String name) { // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.class); } @Test @@ -92,8 +87,7 @@ void koreanNameExactly4Chars() { @DisplayName("한글 이름 형식 오류 - 4자 초과") void koreanNameExceeds4Chars() { // when & then - assertThatThrownBy(() -> new Name("홍길동님이")) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name("홍길동님이")).isInstanceOf(CoreException.class); } @Test @@ -114,8 +108,7 @@ void englishNameExceeds50Chars() { String name = "a".repeat(51); // when & then - assertThatThrownBy(() -> new Name(name)) - .isInstanceOf(UserValidationException.class); + assertThatThrownBy(() -> new Name(name)).isInstanceOf(CoreException.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 76% 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..a8d7d6eb5 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.support.error.CoreException; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,40 +15,35 @@ public void testPasswordSuccess() { @Test @DisplayName("대문자 없음") public void throwsExceptionWhenNoUppercase() { - Assertions.assertThatThrownBy(() -> new Password("1q2w3e4r!")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("1q2w3e4r!")).isInstanceOf(CoreException.class) .hasMessage("대문자를 포함해야 합니다"); } @Test @DisplayName("소문자 없음") public void throwsExceptionWhenNoLowercase() { - Assertions.assertThatThrownBy(() -> new Password("1Q2W3E4R!")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("1Q2W3E4R!")).isInstanceOf(CoreException.class) .hasMessage("소문자를 포함해야 합니다"); } @Test @DisplayName("숫자 없음") public void throwsExceptionWhenNoDigit() { - Assertions.assertThatThrownBy(() -> new Password("qQwweerr!")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("qQwweerr!")).isInstanceOf(CoreException.class) .hasMessage("숫자를 포함해야 합니다"); } @Test @DisplayName("특수문자 없음") public void throwsExceptionWhenNoSpecialChar() { - Assertions.assertThatThrownBy(() -> new Password("qQwweerrr1")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("qQwweerrr1")).isInstanceOf(CoreException.class) .hasMessage("특수문자를 포함해야 합니다"); } @Test @DisplayName("길이 미달") public void throwsExceptionWhenTooShort() { - Assertions.assertThatThrownBy(() -> new Password("1Q2w3e!")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("1Q2w3e!")).isInstanceOf(CoreException.class) .hasMessage("비밀번호는 8자 이상이어야 합니다"); } @@ -62,8 +57,7 @@ public void passwordExactly8Chars() { @Test @DisplayName("길이 초과") public void throwsExceptionWhenTooLong() { - Assertions.assertThatThrownBy(() -> new Password("1Q2w3e4r!12345678")) - .isInstanceOf(UserValidationException.class) + Assertions.assertThatThrownBy(() -> new Password("1Q2w3e4r!12345678")).isInstanceOf(CoreException.class) .hasMessage("비밀번호는 16자 이하여야 합니다"); } @@ -73,4 +67,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..05f71fd3b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PhoneTest.java @@ -0,0 +1,78 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +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(CoreException.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(CoreException.class); + } + + @ParameterizedTest + @DisplayName("전화번호 형식 오류 - 빈 문자열 또는 null") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void emptyOrNullPhone(String phone) { + // when & then + assertThatThrownBy(() -> new Phone(phone)).isInstanceOf(CoreException.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(CoreException.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/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java new file mode 100644 index 000000000..98379de5a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java @@ -0,0 +1,247 @@ +package com.loopers.interfaces.api.admin; + +import com.fasterxml.jackson.databind.JsonNode; +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.brand.BrandDto; +import com.loopers.interfaces.api.product.ProductDto; +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.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.request.MockMvcRequestBuilders.put; +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 AdminApiControllerTest { + + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @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("Admin LDAP 인증") + class AdminLdap { + + @Test + @DisplayName("LDAP 헤더 없이 어드민 엔드포인트 호출 시 401") + void returnsUnauthorizedWithoutLdapHeader() throws Exception { + mockMvc.perform(get("/api-admin/v1/brands")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("잘못된 LDAP 값으로 호출 시 403") + void returnsForbiddenWithInvalidLdapHeader() throws Exception { + mockMvc.perform(get("/api-admin/v1/brands") + .header(ADMIN_LDAP_HEADER, "wrong.admin")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("Admin Brand API") + class AdminBrandApi { + + @Test + @DisplayName("브랜드 목록 조회 성공") + void listBrandsSuccess() throws Exception { + brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")); + brandRepository.save(new Brand(new BrandName("브랜드B"), "desc", "img")); + + mockMvc.perform(get("/api-admin/v1/brands") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(2)); + } + + @Test + @DisplayName("브랜드 생성 후 상세 조회 성공") + void createAndGetBrandSuccess() throws Exception { + BrandDto.CreateBrandRequest request = BrandDto.CreateBrandRequest.builder() + .name("브랜드C") + .description("설명") + .imageUrl("https://example.com/image.png") + .build(); + + String body = mockMvc.perform(post("/api-admin/v1/brands") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode json = objectMapper.readTree(body); + long brandId = json.path("data").path("id").asLong(); + + mockMvc.perform(get("/api-admin/v1/brands/{brandId}", brandId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(brandId)); + } + } + + @Nested + @DisplayName("Admin Product API") + class AdminProductApi { + + @Test + @DisplayName("상품 생성/목록/상세/수정/삭제 성공") + void productCrudSuccess() throws Exception { + Long categoryId = categoryRepository.save(new Category("카테고리A")).id(); + Long brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); + + ProductDto.CreateProductRequest createRequest = new ProductDto.CreateProductRequest( + "상품A", + 10000, + 30, + "설명", + categoryId, + brandId + ); + + String createBody = mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andReturn() + .getResponse() + .getContentAsString(); + + long productId = objectMapper.readTree(createBody).path("data").path("id").asLong(); + + mockMvc.perform(get("/api-admin/v1/products") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(1)); + + mockMvc.perform(get("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(productId)); + + ProductDto.UpdateProductRequest updateRequest = new ProductDto.UpdateProductRequest( + "상품A-수정", + 12000, + 25, + "설명-수정", + categoryId, + null + ); + + mockMvc.perform(put("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.name").value("상품A-수정")); + + mockMvc.perform(delete("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + + mockMvc.perform(get("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.deletedAt").isNotEmpty()); + } + + @Test + @DisplayName("상품 수정 시 brandId를 보내면 400") + void updateProductFailsWhenBrandIdProvided() throws Exception { + Long categoryId = categoryRepository.save(new Category("카테고리A")).id(); + Long brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); + + ProductDto.CreateProductRequest createRequest = new ProductDto.CreateProductRequest( + "상품A", + 10000, + 30, + "설명", + categoryId, + brandId + ); + + String createBody = mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + long productId = objectMapper.readTree(createBody).path("data").path("id").asLong(); + + ProductDto.UpdateProductRequest updateRequest = new ProductDto.UpdateProductRequest( + "상품A-수정", + 12000, + 25, + "설명-수정", + categoryId, + brandId + ); + + mockMvc.perform(put("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isBadRequest()); + } + } +} 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..274e378f8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -0,0 +1,277 @@ +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_ADMIN_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + 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" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); + + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_BRANDS, + HttpMethod.POST, + new HttpEntity<>(request, headers), + 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..3292e8c25 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java @@ -0,0 +1,495 @@ +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.like.LikeJpaRepository; +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 static final String ENDPOINT_ADMIN_BRANDS = "/api-admin/v1/brands"; + private static final String HEADER_ADMIN_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final ProductJpaRepository productJpaRepository; + private final LikeJpaRepository likeJpaRepository; + + private Long brandId; + private Long categoryId; + + @Autowired + public LikeApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + ProductJpaRepository productJpaRepository, + LikeJpaRepository likeJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.productJpaRepository = productJpaRepository; + this.likeJpaRepository = likeJpaRepository; + } + + @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("브랜드 삭제 시 관련 상품 좋아요가 삭제되고 내 좋아요 목록에서 제외된다") + void getMyLikes_whenBrandDeleted_removesRelatedLikesAndExcludesProducts() { + registerMember("likeApiBrandDeleted", "Password1!", "홍길동", "19900101", "api-like-brand-deleted@example.com", "010-9032-3456"); + Long productId = createProduct("브랜드 삭제 대상 상품", 10_000, 50); + HttpHeaders headers = headers("likeApiBrandDeleted", "Password1!"); + + testRestTemplate.exchange( + productLikesUrl(productId), + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() { + } + ); + + assertThat(likeJpaRepository.existsByMemberIdAndProductId("likeApiBrandDeleted", productId)).isTrue(); + + deleteBrandAsAdmin(brandId); + + assertThat(likeJpaRepository.existsByMemberIdAndProductId("likeApiBrandDeleted", productId)).isFalse(); + + 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 deleteBrandAsAdmin(long targetBrandId) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN_BRANDS + "/" + targetBrandId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() { + } + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + 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..80ed456e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.query.ProductListCriteria; +import com.loopers.domain.product.query.ProductListQuery; +import com.loopers.domain.product.query.ProductSortOption; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductQueryTest { + + private final ProductService productService = new ProductService(); + + @Test + @DisplayName("sort 값이 비어 있으면 최신순 정렬을 사용한다") + void defaultSortIsLatest() { + ProductListQuery query = new ProductListQuery(null, null, null, null); + ProductListCriteria criteria = productService.toCriteria(query); + + assertThat(criteria.sortOption()).isEqualTo(ProductSortOption.LATEST); + } + + @Test + @DisplayName("허용된 sort 값은 매핑된다") + void mapSupportedSortValues() { + ProductListCriteria priceCriteria = productService.toCriteria(new ProductListQuery(null, "price_asc", null, null)); + ProductListCriteria likesCriteria = productService.toCriteria(new ProductListQuery(null, "likes_desc", null, null)); + + assertThat(priceCriteria.sortOption()).isEqualTo(ProductSortOption.PRICE_ASC); + assertThat(likesCriteria.sortOption()).isEqualTo(ProductSortOption.LIKES_DESC); + } + + @Test + @DisplayName("지원하지 않는 sort 값은 400 에러를 발생시킨다") + void invalidSortValueFailsWithBadRequest() { + assertThatThrownBy(() -> productService.toCriteria(new ProductListQuery(null, "invalid", null, null))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("리스트 쿼리는 기본 페이지 값으로 Criteria를 만든다") + void listQueryCreatesDefaultCriteria() { + ProductListCriteria criteria = productService.toCriteria(new ProductListQuery(null, null, null, null)); + + assertThat(criteria.page()).isEqualTo(ProductListCriteria.DEFAULT_PAGE); + assertThat(criteria.size()).isEqualTo(ProductListCriteria.DEFAULT_SIZE); + assertThat(criteria.sortOption()).isEqualTo(ProductSortOption.LATEST); + } + + @Test + @DisplayName("페이지 값이 음수면 400 에러를 발생시킨다") + void invalidPageValueFailsWithBadRequest() { + assertThatThrownBy(() -> productService.toCriteria(new ProductListQuery(null, null, -1, 20))) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @Test + @DisplayName("크기가 1 미만이면 400 에러를 발생시킨다") + void invalidSizeValueFailsWithBadRequest() { + assertThatThrownBy(() -> productService.toCriteria(new ProductListQuery(null, null, 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 index e8ef29dd4..841d87932 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -4,7 +4,7 @@ **좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 애견용품 이커머스**. -내가 좋아하는 브랜드의 애견용품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결돼요. +내가 좋아하는 브랜드의 애견용품들을 한 번에 담아 주문하고, 멤버 행동은 랭킹과 추천으로 연결돼요. 우린 이 흐름을 하나씩 직접 만들어갈 거예요. @@ -15,7 +15,7 @@ 1. 사용자가 **회원가입**을 하고 2. 여러 브랜드의 애견용품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. 3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. -4. 유저의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. +4. 멤버의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. --- @@ -24,9 +24,9 @@ - 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. ``` - 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 멤버 로그인이 필요한 기능은 아래 헤더를 통해 멤버를 식별해 제공합니다. 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. - 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + 멤버는 타 멤버의 정보에 직접 접근할 수 없습니다. * X-Loopers-LoginId : 로그인 ID * X-Loopers-LoginPw : 비밀번호 @@ -48,19 +48,19 @@ ## ✅ 요구사항 -## 👤 유저 (Users) +## 👤 멤버 (Members) -| **METHOD** | **URI** | **user_required** | **설명** | +| **METHOD** | **URI** | **member_required** | **설명** | | --- | --- | --- | --- | -| POST | `/api/v1/users` | X | 회원가입 | -| GET | `/api/v1/users/me` | O | 내 정보 조회 | -| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | +| POST | `/api/v1/members` | X | 회원가입 | +| GET | `/api/v1/members/me` | O | 내 정보 조회 | +| PUT | `/api/v1/members/password` | O | 비밀번호 변경 | --- ## 🏷 브랜드 & 상품 (Brands / Products) -| **METHOD** | **URI** | **user_required** | **설명** | +| **METHOD** | **URI** | **member_required** | **설명** | | --- | --- | --- | --- | | GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | | GET | `/api/v1/products` | X | 애견용품 목록 조회 | @@ -85,8 +85,8 @@ 반드시 포함되어야 할 설계 내용 1. 브랜드 및 상품: 정보 조회, 목록 조회(정렬/필터 포함), 어드민용 CRUD. 2. 좋아요: 상품 좋아요 등록/취소(토글 방식 지양), 좋아요 목록 조회. -3. 주문(Order): 주문 요청, 유저 주문 목록/상세 조회. 특히 주문 당시의 상품 정보(스냅샷) 저장 구조 설계가 중요합니다. -4. 어드민: 상품/브랜드/주문 관리를 위한 어드민 기능 및 X-ROOPERS-LDAP 헤더를 이용한 인증 설계. +3. 주문(Order): 주문 요청, 멤버 주문 목록/상세 조회. 특히 주문 당시의 상품 정보(스냅샷) 저장 구조 설계가 중요합니다. +4. 어드민: 상품/브랜드/주문 관리를 위한 어드민 기능 및 X-Loopers-Ldap 헤더를 이용한 인증 설계. --- @@ -112,20 +112,20 @@ ## ❤️ 좋아요 (Likes) -| **METHOD** | **URI** | **user_required** | **설명** | +| **METHOD** | **URI** | **member_required** | **설명** | | --- | --- | --- | --- | | POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | | DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | -| GET | `/api/v1/users/{userId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | +| GET | `/api/v1/members/{memberId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | --- ## 🧾 주문 (Orders) -| **METHOD** | **URI** | **user_required** | **설명** | +| **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?startAt=2026-01-31&endAt=2026-02-10` | O | 멤버의 주문 목록 조회 | | GET | `/api/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..a0001fe8d --- /dev/null +++ b/docs/ai-rules/coding-style.md @@ -0,0 +1,59 @@ +# 코딩 스타일 규칙 (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 → 요청/응답 변환만, 비즈니스 로직 금지 +- Controller 조합/오케스트레이션 로직 금지 (여러 도메인 데이터 결합/매핑은 Facade 또는 Service로 이동) +- `@Transactional` → Application Service에만 (Facade/Domain Service 절대 금지) +- Application Service private 메서드 금지 +- Domain Service private 메서드 금지 +- Application/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` 남용 금지 +- Application/Domain Service의 `private` 메서드 선언 금지 +- Application/Domain Service 내부 메서드 간 직접 호출 금지 +- 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 index 4a243178c..1659d5979 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -1,12 +1,12 @@ # PawShop 요구사항 명세서 > 생성일: 2026-02-11 -> 핵심 가치: 브랜드별 애견용품을 탐색-좋아요-주문하는 핵심 쇼핑 플로우를 제공하고, 유저 행동 데이터를 축적하는 이커머스 백엔드 시스템 구축 +> 핵심 가치: 브랜드별 애견용품을 탐색-좋아요-주문하는 핵심 쇼핑 플로우를 제공하고, 멤버 행동 데이터를 축적하는 이커머스 백엔드 시스템 구축 ## 1. 문제 정의 - **사용자 관점**: 여러 브랜드의 애견용품을 한 곳에서 비교/선택하고, 관심 상품을 관리(좋아요)하며 편리하게 주문하고 싶다. -- **비즈니스 관점**: 브랜드별 상품 큐레이션과 유저 행동 데이터(좋아요, 주문)를 축적하여 추후 추천/랭킹 기능의 기반을 마련한다. +- **비즈니스 관점**: 브랜드별 상품 큐레이션과 멤버 행동 데이터(좋아요, 주문)를 축적하여 추후 추천/랭킹 기능의 기반을 마련한다. - **시스템 관점**: 주문 시 상품 스냅샷과 재고 차감의 데이터 일관성을 보장하면서, 쿠폰/결제/추천 등의 확장에 대비한 백엔드 구조가 아직 없다. ## 2. 개념 모델 @@ -18,20 +18,20 @@ ### 핵심 도메인 -- **User** - 회원가입, 인증, 프로필 관리 +- **Member** - 회원가입, 인증, 프로필 관리 - **Brand** - 브랜드 정보 관리 (이름 불변) - **Category** - 상품 카테고리 관리 (Seed 데이터 기반 조회 전용 테이블) - **Product** - 상품 정보 관리 (재고, 가격, 카테고리 참조) -- **Like** - 상품 좋아요 (유저 행동 데이터 축적) +- **Like** - 상품 좋아요 (멤버 행동 데이터 축적) - **Order** - 주문 처리 (스냅샷, 재고 차감, 상태 관리) ### 보조/외부 시스템 - 없음 (모놀리식, 외부 결제 없음) -## 3. User Stories +## 3. Member Stories -### 도메인 1: User +### 도메인 1: Member **기존 구현 완료** (loginId, password, name, email, birthDate). phone 필드 추가 예정. @@ -42,9 +42,9 @@ **So that** PawShop에서 좋아요/주문 등 회원 전용 기능을 이용할 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 유효한 입력값, When POST /api/v1/users, Then 201 Created + 유저 생성 -- [ ] AC2: Given 이미 존재하는 loginId, When POST /api/v1/users, Then 409 Conflict -- [ ] AC3: Given 필수값 누락 또는 형식 오류(email, phone), When POST /api/v1/users, Then 400 Bad Request +- [ ] 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 사용 가능/불가능 응답 **비즈니스 규칙:** @@ -62,8 +62,8 @@ **So that** 내 계정 정보를 확인할 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 유효한 인증 헤더, When GET /api/v1/users/me, Then 200 + 내 정보 반환 (password 제외, 이름 마스킹) -- [ ] AC2: Given 잘못된 인증 헤더, When GET /api/v1/users/me, Then 401 +- [ ] AC1: Given 유효한 인증 헤더, When GET /api/v1/members/me, Then 200 + 내 정보 반환 (password 제외, 이름 마스킹) +- [ ] AC2: Given 잘못된 인증 헤더, When GET /api/v1/members/me, Then 401 **비즈니스 규칙:** - 이름은 마지막 글자를 *로 마스킹 (예: "홍길동" → "홍길*") @@ -75,7 +75,7 @@ **So that** 계정 보안을 유지할 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 유효한 인증 + 현재PW 일치 + 유효한 새PW, When PATCH /api/v1/users/me/password, Then 200 +- [ ] 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 @@ -131,12 +131,11 @@ **So that** 시스템을 깔끔하게 유지할 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 상품 없는 브랜드, When DELETE /api-admin/v1/brands/{brandId}, Then 200 -- [ ] AC2: Given 상품이 있는 브랜드, When DELETE, Then 409 Conflict -- [ ] AC3: Given 관련 상품에 주문이 있는 브랜드, When DELETE, Then 409 Conflict +- [ ] AC1: Given 브랜드가 존재, When DELETE /api-admin/v1/brands/{brandId}, Then 브랜드와 해당 브랜드의 상품이 함께 soft-delete 처리되고 200 +- [ ] AC2: Given 삭제된 브랜드, When DELETE, Then 404 **비즈니스 규칙:** -- 해당 브랜드에 상품이 없고, 관련 상품의 주문이 없을 때만 삭제 가능 +- 브랜드 삭제는 해당 브랜드를 soft-delete하고, 관련 상품을 함께 soft-delete한다. --- @@ -220,13 +219,13 @@ **So that** 관심 상품을 기록하고 나중에 다시 찾을 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 인증된 유저 + 활성 상품, When POST /api/v1/products/{productId}/likes, Then 201 + 상품 likeCount 증가 +- [ ] 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에 user_id + product_id Unique 제약조건 +- DB에 member_id + product_id Unique 제약조건 - Product 테이블의 likeCount 필드 업데이트 #### US-13: 상품 좋아요 취소 @@ -246,7 +245,7 @@ **So that** 관심 상품을 한눈에 볼 수 있다 **수용 기준 (AC):** -- [ ] AC1: Given 인증된 유저, When GET /api/v1/me/likes?page=0&size=20, Then 200 + 좋아요한 활성 상품 목록 (페이지네이션) +- [ ] AC1: Given 인증된 멤버, When GET /api/v1/me/likes?page=0&size=20, Then 200 + 좋아요한 활성 상품 목록 (페이지네이션) - [ ] AC2: Given 좋아요한 상품 중 삭제된 상품, Then 목록에서 제외 **쿼리 파라미터:** @@ -289,7 +288,7 @@ - 하나의 트랜잭션에서 재고 확인 → 차감 → 주문 생성 처리 - 결제 없음 (주문 완료 = 결제 완료) -#### US-16: 유저 주문 취소 +#### US-16: 멤버 주문 취소 **As a** 로그인한 회원 **I want to** 내 주문을 취소 @@ -301,7 +300,7 @@ - [ ] AC3: Given 이미 CANCELLED 상태, When PATCH, Then 409 Conflict - [ ] AC4: Given 존재하지 않는 orderId, When PATCH, Then 404 -#### US-17: 유저 주문 목록 조회 +#### US-17: 멤버 주문 목록 조회 **As a** 로그인한 회원 **I want to** 날짜 범위로 내 주문 목록을 조회 @@ -388,11 +387,11 @@ | 용어 | 정의 | |------|------| -| loginId | 사용자 로그인 식별자 (영문 소문자 + 숫자) | +| loginId | 멤버 로그인 식별자 (영문 소문자 + 숫자) | | Brand | 브랜드 (애견용품 제조/판매 브랜드) | | Category | 상품 카테고리 (Seed 데이터 기반 조회 전용 테이블) | | Product | 상품 (개별 애견용품) | -| Like | 좋아요 (사용자의 상품 관심 표시) | +| Like | 좋아요 (멤버의 상품 관심 표시) | | Order | 주문 (하나 이상의 상품을 포함하는 구매 요청) | | OrderItem | 주문 항목 (주문 내 개별 상품 + 수량 + 스냅샷) | | Snapshot | 스냅샷 (주문 시점의 상품 정보 사본 — 주문 번호, 상품명, 가격, 브랜드명) | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index c54c6da52..e1d2e313d 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -151,53 +151,34 @@ sequenceDiagram ## 브랜드 삭제 (DELETE /api-admin/v1/brands/{brandId}) -브랜드 삭제는 참조 무결성 보장이 핵심이다. 상품이 있거나, 관련 상품에 주문이 있는 경우 삭제를 차단하는 로직이 올바른 순서로 동작하는지 검증한다. +브랜드 삭제는 소프트 삭제 정책으로 수행되며, 브랜드 삭제 시 해당 브랜드의 상품도 함께 soft-delete 처리되는지 확인한다. ```mermaid sequenceDiagram autonumber participant C as Client participant BC as BrandAdminController - participant BF as BrandFacade - participant BS as BrandService - participant PS as ProductService - participant OS as OrderService + participant BS as BrandApplicationService + C->>BC: DELETE /api-admin/v1/brands/{brandId} note over BC: LDAP 인증 확인 - BC->>BF: 브랜드 삭제 요청 - - BF->>BS: 브랜드 조회 - BS-->>BF: 브랜드 정보 (없으면 404) - - BF->>PS: 해당 브랜드 상품 존재 확인 - PS-->>BF: 존재 여부 - - alt 상품 존재 - BF-->>BC: 409 Conflict - BC-->>C: 409 (상품 있음) - else 상품 없음 - BF->>OS: 해당 브랜드 관련 주문 존재 확인 - OS-->>BF: 존재 여부 - - alt 관련 주문 존재 - BF-->>BC: 409 Conflict - BC-->>C: 409 (주문 있음) - else 주문 없음 - BF->>BS: 브랜드 삭제 - BS-->>BF: 삭제 완료 - - BF-->>BC: 삭제 완료 - BC-->>C: 200 OK - end - end + BC->>BS: 브랜드 삭제 요청 + + BS->>BS: 브랜드 조회 + BS-->>BC: 브랜드 정보 (없으면 404) + + BS->>BS: 브랜드 + 연관 상품 삭제 + BS-->>BC: 브랜드 + 연관 상품 삭제 완료 + + BC-->>C: 200 OK ``` ### 핵심 포인트 -- **참조 무결성 순서**: Facade에서 상품 존재 확인 → 주문 존재 확인 → BrandService에 삭제 위임. +- **삭제 정책**: BrandService가 브랜드 삭제와 연관 상품 soft-delete를 단일 트랜잭션 안에서 수행한다. ### 설계 리스크 -- **확인-삭제 사이 갭**: 상품 없음을 확인한 후 삭제 전에 새 상품이 등록될 수 있음. 트랜잭션 격리 수준으로 기본 방어 가능. +- **확인-삭제 갭 제거**: 사전 존재성 검사 분기를 제거하고, 삭제 플로우 내부에서 일괄 soft-delete를 수행해 경쟁 조건을 줄인다. --- diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index aee5c1b5e..ee55e3432 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -34,15 +34,15 @@ classDiagram -LocalDateTime likedAt } - class User { - -UserId loginId + class Member { + -MemberId loginId -Password password -Name name -Email email -BirthDate birthDate -Phone phone +getMaskedName() String - +changePassword(currentPw, newPw) User + +changePassword(currentPw, newPw) Member } class Order { @@ -70,18 +70,18 @@ classDiagram Category "1" -- "*" Product : 분류한다 Product "1" -- "*" Like : 받는다 Product "1" ..> "*" OrderItem : 스냅샷으로 캡처 - User "1" -- "*" Like : 좋아요한다 - User "1" -- "*" Order : 주문한다 + Member "1" -- "*" Like : 좋아요한다 + Member "1" -- "*" Order : 주문한다 Order "1" *-- "*" OrderItem : 포함한다 Order -- OrderStatus ``` ## 핵심 포인트 -- **불변 도메인 객체**: 기존 User가 record 기반 불변 객체 + Value Object(UserId, Password, Name, Email, BirthDate) 패턴으로 구현되어 있다. 새 도메인도 동일 패턴 적용. +- **불변 도메인 객체**: 기존 Member가 record 기반 불변 객체 + Value Object(MemberId, Password, Name, Email, BirthDate) 패턴으로 구현되어 있다. 새 도메인도 동일 패턴 적용. - **엔티티에 비즈니스 로직 배치**: Product.decreaseStock(), Order.cancel() 등 상태 변경 로직이 Service가 아닌 엔티티 자체에 위치하여 빈약한 도메인 방지. - **스냅샷 분리 (점선)**: OrderItem은 Product의 런타임 참조를 갖지 않는다. 주문 시점의 상품명/가격/브랜드명을 스냅샷 필드로 복사하여 Product 변경/삭제에 영향받지 않음. -- **Like = 조인 엔티티**: User-Product 간 N:M 관계를 Like 엔티티로 풀어낸다. DB에서 (userId + productId) Unique 제약조건으로 중복 방지. +- **Like = 조인 엔티티**: Member-Product 간 N:M 관계를 Like 엔티티로 풀어낸다. DB에서 (memberId + productId) Unique 제약조건으로 중복 방지. - **Brand.name 불변**: updateInfo()는 description, imageUrl만 수정 가능. name은 생성 시 확정. - **Category는 Seed 데이터**: 비즈니스 메서드 없음. 조회 전용 참조 테이블. diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 0688120b8..06532752e 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -53,10 +53,21 @@ erDiagram ``` ## 핵심 포인트 -- **Like = User-Product N:M 조인 엔티티**: User와 Product 간 N:M 관계를 Like 엔티티로 풀어냈다. DB에서 (userId + productId) Unique 제약조건으로 중복 방지. Like 자체에 비즈니스 속성은 없으므로 속성 블록을 생략했다. +- **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/scripts/check_rules.py b/scripts/check_rules.py new file mode 100644 index 000000000..0e80f417b --- /dev/null +++ b/scripts/check_rules.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class Violation: + rule_id: str + path: Path + line: int + message: str + + +ROOT = Path(__file__).resolve().parents[1] +MAIN_JAVA = ROOT / "apps/commerce-api/src/main/java/com/loopers" +TEST_ROOT = ROOT / "apps/commerce-api/src/test" + +PARAMETER_COUNT_WHITELIST: set[tuple[str, str]] = { + ( + "apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java", + "findByUserId", + ), + ( + "apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminLdapInterceptor.java", + "preHandle", + ), +} + + +def read_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() + + +def java_files(base: Path) -> list[Path]: + if not base.exists(): + return [] + return sorted(base.rglob("*.java")) + + +def rel(path: Path) -> Path: + return path.relative_to(ROOT) + + +def add_simple_pattern_violations( + violations: list[Violation], + files: Iterable[Path], + rule_id: str, + pattern: re.Pattern[str], + message: str, +) -> None: + for file_path in files: + for idx, line in enumerate(read_lines(file_path), start=1): + if pattern.search(line): + violations.append(Violation(rule_id, rel(file_path), idx, message)) + + +def check_controller_orchestration(violations: list[Violation]) -> None: + controllers = sorted((MAIN_JAVA / "interfaces/api").rglob("*Controller.java")) + patterns = [ + (re.compile(r"\.stream\("), "Controller must not orchestrate stream composition"), + (re.compile(r"Collectors\."), "Controller must not orchestrate collectors composition"), + (re.compile(r"\.collect\("), "Controller must not orchestrate collection mapping"), + ] + for file_path in controllers: + lines = read_lines(file_path) + for idx, line in enumerate(lines, start=1): + for pattern, message in patterns: + if pattern.search(line): + violations.append( + Violation("CONTROLLER_ORCHESTRATION", rel(file_path), idx, message) + ) + for idx, line in enumerate(lines, start=1): + if re.search(r"private\s+final\s+.*Repository\b", line): + violations.append( + Violation( + "CONTROLLER_REPOSITORY_DEP", + rel(file_path), + idx, + "Controller must not depend on Repository directly", + ) + ) + + +def check_private_methods_in_services(violations: list[Violation]) -> None: + targets = [] + targets.extend(sorted((MAIN_JAVA / "application").rglob("*Service.java"))) + targets.extend(sorted((MAIN_JAVA / "domain").rglob("*Service.java"))) + + for file_path in targets: + class_name = file_path.stem + for idx, line in enumerate(read_lines(file_path), start=1): + stripped = line.strip() + if not stripped.startswith("private "): + continue + if "(" not in stripped or ")" not in stripped: + continue + if stripped.startswith("private final") or stripped.startswith("private static"): + continue + method_name = stripped.split("(")[0].split()[-1] + if method_name == class_name: + continue + violations.append( + Violation( + "NO_PRIVATE_IN_SERVICE", + rel(file_path), + idx, + "Application/Domain Service must not declare private methods", + ) + ) + + +def extract_service_method_names(lines: list[str], class_name: str) -> set[str]: + names: set[str] = set() + decl = re.compile( + r"^\s*(?:public|protected|private)\s+(?:static\s+)?[\w<>,\[\]?\s]+\s+(\w+)\s*\(" + ) + for line in lines: + stripped = line.strip() + if stripped.startswith("public record") or stripped.startswith("record "): + continue + m = decl.match(line) + if not m: + continue + name = m.group(1) + if name == class_name: + continue + names.add(name) + return names + + +def is_method_declaration_line(line: str, method_name: str) -> bool: + pattern = re.compile( + rf"^\s*(?:public|protected|private)\s+(?:static\s+)?[\w<>,\[\]?\s]+\s+{re.escape(method_name)}\s*\(" + ) + return bool(pattern.match(line)) + + +def check_intra_service_method_calls(violations: list[Violation]) -> None: + targets = [] + targets.extend(sorted((MAIN_JAVA / "application").rglob("*Service.java"))) + targets.extend(sorted((MAIN_JAVA / "domain").rglob("*Service.java"))) + + for file_path in targets: + lines = read_lines(file_path) + method_names = extract_service_method_names(lines, file_path.stem) + if not method_names: + continue + + for idx, line in enumerate(lines, start=1): + stripped = line.strip() + if stripped.startswith("//"): + continue + for method_name in method_names: + if is_method_declaration_line(line, method_name): + continue + + direct_call = re.search(rf"(? None: + facades = sorted((MAIN_JAVA / "application").rglob("*Facade.java")) + for file_path in facades: + for idx, line in enumerate(read_lines(file_path), start=1): + if re.search(r"import\s+com\.loopers\.domain\..*(Repository|Service);", line): + violations.append( + Violation( + "FACADE_DOMAIN_DEP", + rel(file_path), + idx, + "Facade must depend on Application Service only", + ) + ) + + +def own_application_domain(file_path: Path) -> str | None: + parts = file_path.parts + if "application" not in parts: + return None + idx = parts.index("application") + if idx + 1 >= len(parts): + return None + return parts[idx + 1] + + +def check_application_service_dependencies(violations: list[Violation]) -> None: + services = sorted((MAIN_JAVA / "application").rglob("*Service.java")) + for file_path in services: + own_domain = own_application_domain(file_path) + if own_domain is None: + continue + for idx, line in enumerate(read_lines(file_path), start=1): + m_domain = re.search( + r"import\s+com\.loopers\.domain\.([a-z0-9_]+)\..*(Repository|Service);", line + ) + if m_domain and m_domain.group(1) != own_domain: + violations.append( + Violation( + "APP_SERVICE_CROSS_DOMAIN_DEP", + rel(file_path), + idx, + ( + "Application Service must depend on own-domain Repository/Domain Service only " + f"(own={own_domain}, imported={m_domain.group(1)})" + ), + ) + ) + + m_app = re.search( + r"import\s+com\.loopers\.application\.([a-z0-9_]+)\..*Service;", line + ) + if m_app and m_app.group(1) != own_domain: + violations.append( + Violation( + "APP_SERVICE_DIRECT_APP_CALL", + rel(file_path), + idx, + ( + "Application Service direct dependency on other Application Service is forbidden " + f"(own={own_domain}, imported={m_app.group(1)})" + ), + ) + ) + + +def check_transactional_scope(violations: list[Violation]) -> None: + for file_path in java_files(MAIN_JAVA): + allowed = "/application/" in file_path.as_posix() and file_path.name.endswith("Service.java") + for idx, line in enumerate(read_lines(file_path), start=1): + if "@Transactional" in line and not allowed: + violations.append( + Violation( + "TX_SCOPE", + rel(file_path), + idx, + "@Transactional is allowed only in Application Service", + ) + ) + + +def check_non_admin_deleted_visibility(violations: list[Violation]) -> None: + controllers = sorted((MAIN_JAVA / "interfaces/api").rglob("*Controller.java")) + include_deleted_pattern = re.compile(r"IncludingDeleted|getIncludingDeleted|listIncludingDeleted") + for file_path in controllers: + if "/interfaces/api/admin/" in file_path.as_posix(): + continue + for idx, line in enumerate(read_lines(file_path), start=1): + if include_deleted_pattern.search(line): + violations.append( + Violation( + "NON_ADMIN_INCLUDE_DELETED", + rel(file_path), + idx, + "Non-admin query path must not include deleted entities", + ) + ) + + +def check_misc_coding_rules(violations: list[Violation]) -> None: + all_java = java_files(MAIN_JAVA) + add_simple_pattern_violations( + violations, + all_java, + "NO_SYSTEM_OUT", + re.compile(r"System\.out\.println\("), + "System.out.println is forbidden", + ) + add_simple_pattern_violations( + violations, + all_java, + "NO_SUPPRESS_WARNINGS", + re.compile(r"@SuppressWarnings"), + "@SuppressWarnings overuse is forbidden", + ) + + case_pattern = re.compile(r"\.to(?:Lower|Upper)Case\(([^)]*)\)") + for file_path in all_java: + for idx, line in enumerate(read_lines(file_path), start=1): + for match in case_pattern.finditer(line): + arg = match.group(1).strip() + if "Locale.ROOT" not in arg: + violations.append( + Violation( + "LOCALE_ROOT_REQUIRED", + rel(file_path), + idx, + "Case conversion must use Locale.ROOT", + ) + ) + + +def check_test_rules(violations: list[Violation]) -> None: + if not TEST_ROOT.exists(): + return + java_tests = sorted(TEST_ROOT.rglob("*.java")) + add_simple_pattern_violations( + violations, + java_tests, + "NO_H2_IN_TEST", + re.compile(r"\bH2\b|jdbc:h2", re.IGNORECASE), + "H2 in-memory DB is forbidden in integration tests", + ) + + +def check_parameter_count_rule(violations: list[Violation]) -> None: + method_decl = re.compile( + r"^\s*(public|protected)\s+(?:static\s+)?[\w<>,\[\]?\s]+\s+(\w+)\s*\(([^)]*)\)\s*\{" + ) + + for file_path in java_files(MAIN_JAVA): + rel_path = rel(file_path) + if "/domain/" in rel_path.as_posix(): + continue + + class_name = file_path.stem + for idx, line in enumerate(read_lines(file_path), start=1): + stripped = line.strip() + if " class " in stripped or " interface " in stripped or " record " in stripped: + continue + + m = method_decl.match(line) + if not m: + continue + + method_name = m.group(2) + params = m.group(3).strip() + if method_name == class_name: + continue + + param_count = 0 if not params else params.count(",") + 1 + if param_count < 3: + continue + + if (str(rel_path), method_name) in PARAMETER_COUNT_WHITELIST: + continue + + violations.append( + Violation( + "MAX_PARAMS_NON_DOMAIN", + rel_path, + idx, + "Non-domain method with 3+ parameters must use DTO/query object", + ) + ) + + +def print_report(violations: list[Violation]) -> None: + if not violations: + print("No rule violations found.") + return + + grouped: dict[str, list[Violation]] = {} + for v in violations: + grouped.setdefault(v.rule_id, []).append(v) + + print(f"Found {len(violations)} rule violation(s).") + for rule_id in sorted(grouped): + print(f"\n[{rule_id}] {len(grouped[rule_id])}") + for v in sorted(grouped[rule_id], key=lambda x: (str(x.path), x.line)): + print(f"- {v.path}:{v.line} :: {v.message}") + + +def main() -> int: + violations: list[Violation] = [] + check_controller_orchestration(violations) + check_private_methods_in_services(violations) + check_intra_service_method_calls(violations) + check_facade_dependencies(violations) + check_application_service_dependencies(violations) + check_transactional_scope(violations) + check_non_admin_deleted_visibility(violations) + check_misc_coding_rules(violations) + check_test_rules(violations) + check_parameter_count_rule(violations) + print_report(violations) + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main())