diff --git a/.claude/hooks/tdd-notion-logger.py b/.claude/hooks/tdd-notion-logger.py new file mode 100755 index 000000000..04ffbfcb4 --- /dev/null +++ b/.claude/hooks/tdd-notion-logger.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +""" +TDD Notion Logger Hook for Claude Code. + +PostToolUse hook that logs TDD Red-Green-Refactor cycles to a Notion page +with AI reasoning extracted from the conversation transcript. +""" + +import json +import os +import re +import sys +import urllib.request +import urllib.error +from datetime import datetime, timezone, timedelta + +NOTION_PAGE_ID = "2fc2e1bd53b2809cbd5ed9009dc775bd" +NOTION_API_VERSION = "2022-06-28" +KST = timezone(timedelta(hours=9)) + +TEST_FILE_PATTERN = re.compile(r".*Test\.java$") +JAVA_FILE_PATTERN = re.compile(r".*\.java$") + + +def main(): + hook_input = json.loads(sys.stdin.read()) + + tool_name = hook_input.get("tool_name", "") + if tool_name != "Bash": + return + + command = hook_input.get("tool_input", {}).get("command", "") + if not re.search(r"gradlew.*test", command): + return + + tool_response = hook_input.get("tool_response", {}) + stdout = extract_stdout(tool_response) + + if "BUILD SUCCESSFUL" not in stdout: + return + + notion_api_key = os.environ.get("NOTION_API_KEY") + if not notion_api_key: + sys.stderr.write("NOTION_API_KEY environment variable not set\n") + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path or not os.path.exists(transcript_path): + sys.stderr.write(f"Transcript not found: {transcript_path}\n") + return + + phases = parse_tdd_phases(transcript_path) + + test_class = extract_test_class(command) + test_methods = extract_test_methods(stdout) + timestamp = datetime.now(KST).strftime("%Y-%m-%d %H:%M") + + blocks = build_notion_blocks(test_class, timestamp, phases, test_methods) + append_blocks_to_notion(notion_api_key, blocks) + + +# --------------------------------------------------------------------------- +# Stdout / metadata extraction (unchanged) +# --------------------------------------------------------------------------- + +def extract_stdout(tool_response): + """Extract stdout text from tool_response, handling various formats.""" + if isinstance(tool_response, str): + return tool_response + if isinstance(tool_response, dict): + if "stdout" in tool_response: + return tool_response["stdout"] + if "content" in tool_response: + return str(tool_response["content"]) + if isinstance(tool_response, list): + parts = [] + for item in tool_response: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item.get("text", "")) + return "\n".join(parts) + return str(tool_response) + + +def extract_test_class(command): + """Extract test class name from gradlew test command.""" + match = re.search(r'--tests\s+"?\*?([A-Za-z0-9_.]+)"?', command) + if match: + name = match.group(1) + return name.rsplit(".", 1)[-1] if "." in name else name + return "UnknownTest" + + +def extract_test_methods(stdout): + """Extract executed test method names from test output.""" + methods = [] + for line in stdout.split("\n"): + match = re.search(r">\s+(\w+)\(\)\s+PASSED", line) + if match: + methods.append(match.group(1)) + return methods + + +# --------------------------------------------------------------------------- +# Transcript parsing — TDD phase extraction with AI reasoning +# --------------------------------------------------------------------------- + +def read_recent_entries(transcript_path, max_entries=500): + """Read the most recent entries from a JSONL transcript file.""" + entries = [] + try: + with open(transcript_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (OSError, IOError): + return [] + return entries[-max_entries:] + + +def find_tdd_cycle_entries(entries): + """Return entries between the last two gradlew test Bash commands. + + This scopes the parsing to only the current TDD cycle. + """ + test_run_indices = [] + for i, entry in enumerate(entries): + if entry.get("type") != "assistant": + continue + for content in entry.get("message", {}).get("content", []): + if (content.get("type") == "tool_use" + and content.get("name") == "Bash" + and re.search(r"gradlew.*test", + content.get("input", {}).get("command", ""))): + test_run_indices.append(i) + break + + if not test_run_indices: + return entries + + if len(test_run_indices) < 2: + return entries[: test_run_indices[-1]] + + start = test_run_indices[-2] + 1 + end = test_run_indices[-1] + return entries[start:end] + + +def extract_reasoning_from_entry(entry): + """Extract visible reasoning text from an assistant message. + + Prefers ``text`` blocks (visible to user). Falls back to a truncated + ``thinking`` block summary when no text is available. + """ + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + return "" + + text_parts = [] + thinking_parts = [] + + for content in content_list: + if content.get("type") == "text": + t = content.get("text", "").strip() + if t: + text_parts.append(t) + elif content.get("type") == "thinking": + t = content.get("thinking", "").strip() + if t: + thinking_parts.append(t) + + if text_parts: + return "\n".join(text_parts) + if thinking_parts: + combined = "\n".join(thinking_parts) + return combined[:800] + ("..." if len(combined) > 800 else "") + return "" + + +def extract_test_names(tool_input): + """Extract test method names from Write/Edit content.""" + names = [] + content = tool_input.get("content", "") or tool_input.get("new_string", "") + if not content: + return names + + for match in re.finditer( + r"(?:@Test|@DisplayName)\s*(?:\(\"([^\"]+)\"\))?\s*\n\s*(?:void\s+(\w+))?", + content, + ): + display_name = match.group(1) + method_name = match.group(2) + name = display_name or method_name + if name and name not in names: + names.append(name) + + if not names: + for match in re.finditer(r"void\s+(\w+)\s*\(", content): + name = match.group(1) + if name not in names: + names.append(name) + + return names + + +def parse_tdd_phases(transcript_path): + """Parse transcript JSONL into TDD phases with AI reasoning. + + Returns ``{"red": [...], "green": [...], "refactor": [...]}``. + Each entry: ``{"reasoning": str, "files": [str], "test_names": [str]}``. + """ + entries = read_recent_entries(transcript_path, max_entries=500) + cycle_entries = find_tdd_cycle_entries(entries) + + phases = {"red": [], "green": [], "refactor": []} + green_files_seen = set() + + for entry in cycle_entries: + if entry.get("type") != "assistant": + continue + + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + continue + + test_files = [] + source_files = [] + test_names = [] + + for content in content_list: + if content.get("type") != "tool_use": + continue + if content.get("name") not in ("Write", "Edit"): + continue + + file_path = content.get("input", {}).get("file_path", "") + if not file_path: + continue + + filename = os.path.basename(file_path) + + if TEST_FILE_PATTERN.match(filename): + if filename not in test_files: + test_files.append(filename) + test_names.extend( + n for n in extract_test_names(content.get("input", {})) + if n not in test_names + ) + elif JAVA_FILE_PATTERN.match(filename): + if filename not in source_files: + source_files.append(filename) + + if not test_files and not source_files: + continue + + reasoning = extract_reasoning_from_entry(entry) + + if test_files: + phases["red"].append({ + "reasoning": reasoning, + "files": test_files, + "test_names": test_names, + }) + + if source_files: + all_seen = all(f in green_files_seen for f in source_files) + if green_files_seen and all_seen: + phases["refactor"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + else: + phases["green"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + green_files_seen.update(source_files) + + return phases + + +# --------------------------------------------------------------------------- +# Notion block builders +# --------------------------------------------------------------------------- + +def truncate_text(text, max_len=1900): + """Truncate text to fit Notion rich_text limit (2000 chars).""" + if len(text) <= max_len: + return text + return text[:max_len] + "..." + + +def make_heading2(text): + return { + "object": "block", + "type": "heading_2", + "heading_2": { + "rich_text": [{"type": "text", "text": {"content": truncate_text(text)}}] + }, + } + + +def make_paragraph(text, bold_prefix=None): + rich_text = [] + if bold_prefix: + rich_text.append({ + "type": "text", + "text": {"content": bold_prefix}, + "annotations": {"bold": True}, + }) + + parts = re.split(r"(`[^`]+`)", text) + for part in parts: + if part.startswith("`") and part.endswith("`"): + rich_text.append({ + "type": "text", + "text": {"content": part[1:-1]}, + "annotations": {"code": True}, + }) + elif part: + rich_text.append({ + "type": "text", + "text": {"content": truncate_text(part)}, + }) + + return { + "object": "block", + "type": "paragraph", + "paragraph": {"rich_text": rich_text}, + } + + +def make_bulleted_list(text): + return { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [{"type": "text", "text": {"content": text}}] + }, + } + + +def make_toggle(title, children_blocks, color="default"): + """Create a Notion toggle block with nested children.""" + return { + "object": "block", + "type": "toggle", + "toggle": { + "rich_text": [{"type": "text", "text": {"content": title}}], + "color": color, + "children": children_blocks, + }, + } + + +def make_divider(): + return {"object": "block", "type": "divider", "divider": {}} + + +# --------------------------------------------------------------------------- +# Notion block assembly +# --------------------------------------------------------------------------- + +def build_notion_blocks(test_class, timestamp, phases, test_methods): + """Build Notion API block children payload with AI reasoning per phase.""" + blocks = [] + + blocks.append(make_heading2(f"{test_class} ({timestamp})")) + + # --- Red phase --- + red_summary = _format_red_summary(phases["red"], test_methods) + blocks.append(make_paragraph(red_summary, bold_prefix="Red: ")) + for entry in phases["red"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="red_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Green phase --- + green_summary = "테스트 통과를 위한 구현" if phases["green"] else "테스트 통과 확인" + blocks.append(make_paragraph(green_summary, bold_prefix="Green: ")) + for entry in phases["green"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="green_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Refactor phase --- + if phases["refactor"]: + blocks.append(make_paragraph("코드 품질 개선", bold_prefix="Refactor: ")) + for entry in phases["refactor"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="blue_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"파일: {f}")) + + # --- Result --- + blocks.append(make_paragraph("BUILD SUCCESSFUL", bold_prefix="Result: ")) + blocks.append(make_divider()) + + # Safety: Notion allows max 100 blocks per request + return blocks[:100] + + +def _format_red_summary(red_entries, test_methods): + """Format the Red phase summary line.""" + all_names = [] + for entry in red_entries: + all_names.extend(entry["test_names"]) + + if all_names: + names = ", ".join(f"`{n}`" for n in all_names[:5]) + return f"{names} 테스트 작성" + if test_methods: + names = ", ".join(f"`{m}`" for m in test_methods[:5]) + return f"{names} 테스트 작성" + return "테스트 작성" + + +# --------------------------------------------------------------------------- +# Notion API call (unchanged) +# --------------------------------------------------------------------------- + +def append_blocks_to_notion(api_key, blocks): + """Append blocks to the Notion page using urllib (no external deps).""" + url = f"https://api.notion.com/v1/blocks/{NOTION_PAGE_ID}/children" + payload = json.dumps({"children": blocks}).encode("utf-8") + + req = urllib.request.Request( + url, + data=payload, + method="PATCH", + headers={ + "Authorization": f"Bearer {api_key}", + "Notion-Version": NOTION_API_VERSION, + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status != 200: + sys.stderr.write(f"Notion API returned status {resp.status}\n") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + sys.stderr.write(f"Notion API error {e.code}: {body}\n") + except urllib.error.URLError as e: + sys.stderr.write(f"Notion API connection error: {e.reason}\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..61fadfa1c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "source ~/.zshrc 2>/dev/null; python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-notion-logger.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..5d525720f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew build:*)", + "Bash(java -version:*)", + "Bash(echo:*)", + "Bash(/usr/libexec/java_home:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew build:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew:*)", + "Bash(docker info:*)", + "Bash(docker context inspect:*)", + "Bash(docker run:*)", + "Bash(docker context:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_HOST=unix:///Users/praesentia/.docker/run/docker.sock ./gradlew:*)", + "Bash(~/.testcontainers.properties)", + "Bash(curl:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock ./gradlew:*)", + "Bash(./gradlew:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_API_VERSION=1.44 ./gradlew:*)", + "Bash(python3:*)", + "WebSearch", + "WebFetch(domain:github.com)", + "Bash(osascript:*)", + "Bash(docker:*)" + ] + } +} diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. diff --git a/.gitignore b/.gitignore index 5a979af6f..f9bd50072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,4 @@ -HELP.md -.gradle +.DS_Store +.idea/ build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Kotlin ### -.kotlin +.gradle/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ed3941fda --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-module Spring Boot 3.4.4 / Java 21 template project with clean architecture. The actual codebase is in `loop-pack-be-l2-vol3-java/`. + +## Build & Test Commands + +```bash +# Build +./gradlew build + +# Run all tests +./gradlew test + +# Run single test class +./gradlew test --tests "ExampleV1ApiE2ETest" + +# Run tests matching pattern +./gradlew test --tests "*ModelTest" + +# Generate coverage report +./gradlew jacocoTestReport +``` + +## Local Development Infrastructure + +```bash +# Start MySQL, Redis (master/replica), Kafka +docker-compose -f ./docker/infra-compose.yml up + +# Start Prometheus + Grafana (localhost:3000, admin/admin) +docker-compose -f ./docker/monitoring-compose.yml up +``` + +## Module Structure + +``` +loop-pack-be-l2-vol3-java/ +├── apps/ # Executable Spring Boot applications +│ ├── commerce-api # REST API service +│ ├── commerce-batch # Batch processing +│ └── commerce-streamer # Kafka event streaming +├── modules/ # Reusable configuration modules +│ ├── jpa # JPA + QueryDSL config +│ ├── redis # Redis cache config +│ └── kafka # Kafka config +└── supports/ # Add-on utilities + ├── jackson # JSON serialization + ├── logging # Structured logging + └── monitoring # Prometheus/Grafana metrics +``` + +## Architecture Layers (per app) + +- `interfaces/api/` - REST Controllers + DTOs (request/response records) +- `application/` - Facades/Use Cases (business orchestration) +- `domain/` - Business logic, entities, domain services +- `infrastructure/` - JPA repositories, external integrations + +## Key Conventions + +### Entity Design +All entities extend `BaseEntity` (`modules/jpa/.../domain/BaseEntity.java`): +- Auto-managed: `id`, `createdAt`, `updatedAt`, `deletedAt` +- Soft-delete via idempotent `delete()` / `restore()` methods +- Override `guard()` for validation (called on PrePersist/PreUpdate) + +### Error Handling +Use `CoreException` with `ErrorType` enum: +```java +throw new CoreException(ErrorType.NOT_FOUND); +throw new CoreException(ErrorType.BAD_REQUEST, "Custom message"); +``` +Available: `BAD_REQUEST`, `NOT_FOUND`, `CONFLICT`, `INTERNAL_ERROR` + +### API Response Format +All responses wrapped in `ApiResponse`: +```json +{ + "meta": { "result": "SUCCESS|FAIL", "errorCode": null, "message": null }, + "data": { ... } +} +``` + +### DTO Pattern +Use Java records with nested response classes and static `from()` factories: +```java +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name) { + public static ExampleResponse from(ExampleModel model) { ... } + } +} +``` + +## Testing Strategy + +Three test tiers with naming conventions: +1. **Unit tests** (`*ModelTest`) - Domain logic, no Spring context +2. **Integration tests** (`*IntegrationTest`) - `@SpringBootTest`, uses `DatabaseCleanUp.truncateAllTables()` in `@AfterEach` +3. **E2E tests** (`*E2ETest`) - `@SpringBootTest(webEnvironment=RANDOM_PORT)`, uses `TestRestTemplate` + +Test configuration: +- Profile: `spring.profiles.active=test` +- Timezone: `Asia/Seoul` +- TestContainers for MySQL and Redis + +## Tech Stack + +- Java 21, Spring Boot 3.4.4, Spring Cloud 2024.0.1 +- MySQL 8.0 + JPA + QueryDSL +- Redis 7.0 (master-replica), Kafka 3.5.1 (KRaft mode) +- JUnit 5, Mockito, SpringMockK, Instancio, TestContainers + +## 도메인 & 객체 설계 전략 + +### Entity / VO / Domain Service 책임 분리 +- **Entity** (extends `BaseEntity`): 식별자를 가진 도메인 객체. 생성자에서 불변식 검증, `guard()`는 JPA persist/update 시점 재검증 +- **Value Object** (`@Embeddable`): 값으로 비교되는 불변 객체. `equals/hashCode` 필수 구현, `@NoArgsConstructor(access = PROTECTED)` +- **Domain Service** (`@Component`): 단일 Aggregate 내 비즈니스 규칙 수행. Repository 인터페이스에 의존 (DIP) +- **Facade** (`@Component`, application 레이어): 여러 Domain Service 조합, Info DTO 변환. 트랜잭션은 Domain Service에 위임 + +### 네이밍 규칙 +- Entity: `{Domain}Model` (e.g., `ProductModel`, `BrandModel`) +- Repository Interface: `{Domain}Repository` (domain 패키지) +- Repository Impl: `{Domain}RepositoryImpl` (infrastructure 패키지) +- JPA Repository: `{Domain}JpaRepository` (infrastructure 패키지) +- Service: `{Domain}Service` (domain 패키지, `@Component`) +- Facade: `{Domain}Facade` (application 패키지, `@Component`) +- Info DTO: `{Domain}Info` (application 패키지, record) +- API DTO: `{Domain}V1Dto` (interfaces 패키지, 외부 class + 내부 record) +- API Spec: `{Domain}V1ApiSpec` / `{Domain}AdminV1ApiSpec` (Swagger interface) + +## 아키텍처 & 패키지 구성 전략 + +### 레이어드 아키텍처 + DIP +``` +interfaces/api/ → application/ → domain/ ← infrastructure/ +``` +- domain 레이어는 외부 의존 없음 (Repository는 interface만 정의) +- infrastructure가 domain의 Repository interface를 구현 +- application은 domain service를 조합하여 유스케이스 수행 + +### 패키지 구조 (apps/commerce-api 기준) +``` +com.loopers/ +├── interfaces/api/{domain}/ # Controller, ApiSpec, Dto +├── application/{domain}/ # Facade, Info, Command +├── domain/{domain}/ # Model, Repository(interface), Service, VO, Enum +├── infrastructure/{domain}/ # JpaRepository, RepositoryImpl +├── config/ # Spring Configuration +└── support/ # Error, Util +``` + +## 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. + +## 아키텍처, 패키지 구성 전략 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성하도록 합니다. +- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태로 작성합니다. + - 예시 + > /interfaces/api (presentation 레이어 - API) + /application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공) + /domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치) + /infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공) +- 설계 방식을 여러가지 개발자에게 제안하며, 제안한 부분에 대한 트레이드 오프를 알려줍니다. 그리고 최종 개발자가 선택한 방향으로 개발합니다. + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 given-when-then 원칙으로 작성할 것 +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 + +- ## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file diff --git a/apps/commerce-api/TEST-README.md b/apps/commerce-api/TEST-README.md new file mode 100644 index 000000000..c426b1fcd --- /dev/null +++ b/apps/commerce-api/TEST-README.md @@ -0,0 +1,150 @@ +# Commerce API Test Checklist + +--- + +## Domain - Member + +### LoginIdTest (5) +- [ ] null, 빈 문자열, 공백은 허용하지 않는다 +- [ ] 특수문자나 한글이 포함되면 생성할 수 없다 +- [ ] 영문, 숫자 조합으로 생성할 수 있다 +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### PasswordTest (13) +- [ ] null이나 빈 문자열은 허용하지 않는다 +- [ ] 유효한 비밀번호로 생성할 수 있다 +- [ ] 8~16자 범위 내의 비밀번호는 허용된다 +- [ ] 8~16자 범위를 벗어나면 생성할 수 없다 +- [ ] 한글이 포함되면 생성할 수 없다 +- [ ] YYYYMMDD 형식의 생년월일이 포함되면 예외가 발생한다 +- [ ] YYMMDD 형식의 생년월일이 포함되어도 예외가 발생한다 +- [ ] 생년월일이 포함되지 않으면 통과한다 +- [ ] birthDate가 null이면 검증을 건너뛴다 +- [ ] of 팩토리: 유효한 비밀번호와 생년월일로 생성할 수 있다 +- [ ] of 팩토리: 형식이 잘못되면 INVALID_PASSWORD 예외가 발생한다 +- [ ] of 팩토리: 생년월일이 포함된 비밀번호는 거부한다 +- [ ] of 팩토리: 생년월일이 null이면 형식만 검증한다 + +### MemberNameTest (5) +- [ ] null, 빈 문자열, 공백은 허용하지 않는다 +- [ ] 유효한 이름으로 생성할 수 있다 +- [ ] 글자 수에 따라 마지막 글자를 마스킹한다 (@CsvSource) +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### EmailTest (4) +- [ ] 유효한 이메일로 생성할 수 있다 +- [ ] 이메일 형식이 올바르지 않으면 생성할 수 없다 +- [ ] 같은 값이면 동일하다 (equals) +- [ ] 다른 값이면 다르다 (equals) + +### MemberModelTest (3) +- [ ] 유효한 정보로 생성할 수 있다 +- [ ] email이 null이어도 생성할 수 있다 +- [ ] 새 비밀번호로 변경하면 이전 비밀번호는 매칭되지 않는다 + +### MemberRepositoryTest (3) - Integration +- [ ] 존재하지 않는 loginId면 빈 Optional을 반환한다 +- [ ] 존재하는 loginId면 저장된 회원을 반환한다 +- [ ] 유효한 회원 정보를 저장하면 ID가 생성된다 + +--- + +## Domain - Member Service (Unit) + +### MemberSignupServiceTest (4) +- [ ] 유효한 정보로 가입하면 비밀번호를 암호화하고 저장한다 +- [ ] 이미 사용 중인 loginId면 저장하지 않고 예외를 던진다 +- [ ] loginId 형식이 잘못되면 repository를 조회하지 않는다 +- [ ] 비밀번호 규칙 위반이면 repository를 조회하지 않는다 + +### MemberAuthServiceTest (3) +- [ ] 올바른 자격 증명이면 회원을 반환한다 +- [ ] 존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외를 던진다 +- [ ] 비밀번호가 틀리면 인증 실패 예외를 던진다 + +### MemberPasswordServiceTest (4) +- [ ] 유효한 요청이면 새 비밀번호로 암호화해서 저장한다 +- [ ] 현재 비밀번호가 틀리면 저장하지 않고 예외를 던진다 +- [ ] 새 비밀번호가 현재와 같으면 저장하지 않는다 +- [ ] 새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외를 던진다 + +--- + +## Domain - Member Service (Integration) + +### MemberSignupServiceIntegrationTest (5) +- [ ] 유효한 정보로 가입하면 회원이 생성되고 비밀번호가 암호화된다 +- [ ] 이미 존재하는 loginId로 가입하면 DUPLICATE_LOGIN_ID 예외가 발생한다 +- [ ] loginId 형식이 잘못되면 INVALID_LOGIN_ID 예외가 발생한다 +- [ ] 비밀번호 규칙을 위반하면 INVALID_PASSWORD 예외가 발생한다 +- [ ] 생년월일이 포함된 비밀번호는 거부한다 + +### MemberAuthServiceIntegrationTest (3) +- [ ] 올바른 loginId와 비밀번호면 회원을 반환한다 +- [ ] 비밀번호가 틀리면 인증 실패 예외가 발생한다 +- [ ] 존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외가 발생한다 + +### MemberPasswordServiceIntegrationTest (5) +- [ ] 올바른 현재 비밀번호와 유효한 새 비밀번호면 변경에 성공한다 +- [ ] 현재 비밀번호가 틀리면 PASSWORD_MISMATCH 예외가 발생한다 +- [ ] 새 비밀번호가 현재와 같으면 PASSWORD_SAME_AS_OLD 예외가 발생한다 +- [ ] 새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외가 발생한다 +- [ ] 새 비밀번호에 생년월일이 포함되면 거부한다 + +--- + +## Application + +### MemberFacadeTest (3) +- [ ] 회원가입: SignupService에 위임하고 MemberInfo를 반환한다 +- [ ] 내 정보 조회: 인증 후 마스킹된 이름으로 반환한다 +- [ ] 비밀번호 변경: 인증 후 PasswordService에 위임한다 + +--- + +## E2E (API) + +### MemberV1ApiE2ETest (8) +- [ ] POST /api/v1/members - 유효한 정보로 가입하면 200과 회원 정보를 반환한다 +- [ ] POST /api/v1/members - 중복 loginId로 가입하면 409를 반환한다 +- [ ] POST /api/v1/members - 잘못된 loginId 형식이면 400을 반환한다 +- [ ] GET /api/v1/members/me - 인증 성공 시 마스킹된 이름으로 반환한다 +- [ ] GET /api/v1/members/me - 비밀번호가 틀리면 401을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 유효한 요청이면 200을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 현재 비밀번호가 틀리면 400을 반환한다 +- [ ] PATCH /api/v1/members/me/password - 비밀번호 규칙을 위반하면 400을 반환한다 + +### ExampleV1ApiE2ETest (3) +- [ ] GET /api/v1/examples/{id} - 존재하는 ID면 해당 예시 정보를 반환한다 +- [ ] GET /api/v1/examples/{id} - 숫자가 아닌 ID면 400을 반환한다 +- [ ] GET /api/v1/examples/{id} - 존재하지 않는 ID면 404를 반환한다 + +--- + +## Domain - Example + +### ExampleModelTest (3) +- [ ] 제목과 설명이 모두 주어지면 정상 생성된다 +- [ ] 제목이 공백이면 BAD_REQUEST 예외가 발생한다 +- [ ] 설명이 비어있으면 BAD_REQUEST 예외가 발생한다 + +### ExampleServiceIntegrationTest (2) +- [ ] 존재하는 ID면 해당 예시 정보를 반환한다 +- [ ] 존재하지 않는 ID면 NOT_FOUND 예외가 발생한다 + +--- + +## Support + +### CoreExceptionTest (2) +- [ ] 커스텀 메시지가 없으면 ErrorType의 메시지를 사용한다 +- [ ] 커스텀 메시지가 주어지면 해당 메시지를 사용한다 + +--- + +## Context + +### CommerceApiContextTest (1) +- [ ] Spring Boot 애플리케이션 컨텍스트가 정상적으로 로드된다 diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..9ad4d8ea9 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..7c643c92e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.brand; + +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description) { + return BrandInfo.from(brandService.register(name, description)); + } + + public BrandInfo getBrand(Long brandId) { + return BrandInfo.from(brandService.getBrand(brandId)); + } + + public BrandInfo getBrandForAdmin(Long brandId) { + return BrandInfo.from(brandService.getBrandForAdmin(brandId)); + } + + public BrandInfo update(Long brandId, String name, String description) { + return BrandInfo.from(brandService.update(brandId, name, description)); + } + + @Transactional + public void delete(Long brandId) { + brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..4a7db0896 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +import java.time.ZonedDateTime; + +public record BrandInfo( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static BrandInfo from(BrandModel model) { + return new BrandInfo( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..3fdc5c71d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,85 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); + }); + return brandRepository.save(new BrandModel(name, description)); + } + + @Transactional(readOnly = true) + public BrandModel getBrand(Long brandId) { + BrandModel brand = findById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return brand; + } + + @Transactional(readOnly = true) + public BrandModel getBrandForAdmin(Long brandId) { + return findById(brandId); + } + + @Transactional + public BrandModel update(Long brandId, String name, String description) { + BrandModel brand = findById(brandId); + BrandName newName = new BrandName(name); + + if (!brand.name().equals(newName)) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + } + + brand.update(newName, description); + return brand; + } + + @Transactional + public void delete(Long brandId) { + BrandModel brand = findById(brandId); + brand.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + @Transactional + public BrandModel update(Long id, String name, String description) { + BrandModel brand = getById(id); + brandRepository.findByName(name) + .filter(existing -> !existing.getId().equals(brand.getId())) + .ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); + }); + brand.update(name, description); + return brand; + } + + @Transactional + public void delete(Long id) { + BrandModel brand = getById(id); + brand.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java new file mode 100644 index 000000000..80888765e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -0,0 +1,69 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.dao.DataIntegrityViolationException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponIssueService { + + private final CouponIssueRepository couponIssueRepository; + + @Transactional + public CouponIssueModel issue(CouponModel coupon, Long userId) { + if (coupon.isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다."); + } + + couponIssueRepository.findByUserIdAndCouponId(userId, coupon.getId()) + .ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다."); + }); + + CouponIssueModel couponIssue = new CouponIssueModel(coupon.getId(), userId, coupon.expiredAt()); + try { + return couponIssueRepository.save(couponIssue); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다."); + } + } + + @Transactional(readOnly = true) + public CouponIssueModel getCouponIssue(Long couponIssueId) { + return couponIssueRepository.findById(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + } + + public CouponIssueModel getCouponIssueForUpdate(Long couponIssueId) { + return couponIssueRepository.findByIdForUpdate(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getMyIssues(Long userId) { + return couponIssueRepository.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public Page getIssuesByCoupon(Long couponId, Pageable pageable) { + return couponIssueRepository.findAllByCouponId(couponId, pageable); + } + + @Transactional + public void use(Long couponIssueId, Long userId, Long orderId) { + CouponIssueModel couponIssue = getCouponIssue(couponIssueId); + couponIssue.validateOwner(userId); + couponIssue.use(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java new file mode 100644 index 000000000..09156db15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -0,0 +1,52 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@RequiredArgsConstructor +@Component +public class CouponService { + + private final CouponRepository couponRepository; + + @Transactional + public CouponModel create(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + CouponModel coupon = new CouponModel(name, type, value, minOrderAmount, expiredAt); + return couponRepository.save(coupon); + } + + @Transactional(readOnly = true) + public CouponModel getCoupon(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getAllCoupons(Pageable pageable) { + return couponRepository.findAll(pageable); + } + + @Transactional + public CouponModel update(Long couponId, String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + CouponModel coupon = getCoupon(couponId); + coupon.update(name, type, value, minOrderAmount, expiredAt); + return coupon; + } + + @Transactional + public void delete(Long couponId) { + CouponModel coupon = getCoupon(couponId); + coupon.delete(); + } +} 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/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java similarity index 65% rename from apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java rename to apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java index c0e8431e8..cf61eec61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.example; +package com.loopers.application.example; +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -13,8 +15,9 @@ public class ExampleService { private final ExampleRepository exampleRepository; @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) + public ExampleInfo getExample(Long id) { + ExampleModel example = exampleRepository.find(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + return ExampleInfo.from(example); } } 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..bb5669f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,47 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final LikeTransactionService likeTransactionService; + + public void like(Long userId, Long productId) { + likeTransactionService.doLike(userId, productId); + } + + public void unlike(Long userId, Long productId) { + likeTransactionService.doUnlike(userId, productId); + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long userId, Pageable pageable) { + return likeService.getMyLikes(userId, pageable); + } + + @Transactional(readOnly = true) + public Page getMyLikesWithProducts(Long userId, Pageable pageable) { + Page likes = likeService.getMyLikes(userId, pageable); + return likes.map(like -> { + ProductModel product = productService.getProduct(like.productId()); + return new LikeWithProduct( + like.getId(), + product.getId(), + product.name(), + product.price().value(), + like.getCreatedAt() + ); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..ffc0c09ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long likeId, + Long productId, + String productName, + int productPrice, + String brandName, + ZonedDateTime likedAt +) { + public static LikeInfo from(LikeModel like, ProductModel product, BrandModel brand) { + return new LikeInfo( + like.getId(), + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..3621907fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,33 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +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.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + public LikeModel save(LikeModel like) { + return likeRepository.save(like); + } + + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + public Optional findActiveLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java new file mode 100644 index 000000000..a3b5dd6c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -0,0 +1,41 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeResult; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeToggleService; +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeTransactionService { + + private final LikeService likeService; + private final ProductService productService; + private final LikeToggleService likeToggleService; + + @Transactional + public void doLike(Long userId, Long productId) { + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + LikeResult result = likeToggleService.like(existing, userId, productId); + result.newLike().ifPresent(likeService::save); + + if (result.countChanged()) { + productService.incrementLikeCount(productId); + } + } + + @Transactional + public void doUnlike(Long userId, Long productId) { + Optional activeLike = likeService.findActiveLike(userId, productId); + if (activeLike.isEmpty()) return; + + likeToggleService.unlike(activeLike.get()); + productService.decrementLikeCount(activeLike.get().productId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java new file mode 100644 index 000000000..0d778e2fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java @@ -0,0 +1,11 @@ +package com.loopers.application.like; + +import java.time.ZonedDateTime; + +public record LikeWithProduct( + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java new file mode 100644 index 000000000..5423755d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthService.java @@ -0,0 +1,29 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public MemberModel authenticate(String loginId, String password) { + MemberModel member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.MEMBER_NOT_FOUND)); + + if (!member.matchesPassword(password, passwordEncoder)) { + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } + return member; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..037977724 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,34 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberSignupService memberSignupService; + private final MemberAuthService memberAuthService; + private final MemberPasswordService memberPasswordService; + + public MemberInfo signup(String loginId, String password, String name, + LocalDate birthDate, String email) { + MemberModel member = memberSignupService.signup(loginId, password, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberModel authenticate(String loginId, String password) { + return memberAuthService.authenticate(loginId, password); + } + + public MemberInfo getMyInfo(MemberModel member) { + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(MemberModel member, String currentPassword, String newPassword) { + memberPasswordService.changePassword(member, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..c20d1c9dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import java.time.LocalDate; + +public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { + + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().value(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } + + public static MemberInfo fromWithMaskedName(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().masked(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java new file mode 100644 index 000000000..bab399a37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberPasswordService.java @@ -0,0 +1,36 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberPasswordService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void changePassword(MemberModel member, String currentPassword, String newRawPassword) { + if (!member.matchesPassword(currentPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH); + } + + Password newPassword = new Password(newRawPassword); + + if (member.matchesPassword(newRawPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_SAME_AS_OLD); + } + newPassword.validateAgainst(member.birthDate()); + + member.changePassword(passwordEncoder.encode(newRawPassword)); + memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java new file mode 100644 index 000000000..95afdde38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberSignupService.java @@ -0,0 +1,36 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberSignupService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public MemberModel signup(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + MemberName nameVo = new MemberName(name); + Email emailVo = email != null ? new Email(email) : null; + Password password = Password.of(rawPassword, birthDate); + + memberRepository.findByLoginId(loginId).ifPresent(m -> { + throw new CoreException(ErrorType.DUPLICATE_LOGIN_ID); + }); + + String encodedPassword = passwordEncoder.encode(rawPassword); + MemberModel member = new MemberModel(loginIdVo, encodedPassword, nameVo, birthDate, emailVo); + return memberRepository.save(member); + } +} 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..443496bdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,120 @@ +package com.loopers.application.order; + +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import com.loopers.application.stock.StockService; +import com.loopers.domain.stock.StockModel; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + private final CouponIssueService couponIssueService; + private final CouponService couponService; + + @Transactional + public OrderResult placeOrder(Long userId, List commands, Long couponIssueId) { + // 1. 락 없이: 상품 조회 + 금액 계산 (productId 오름차순 정렬 — 데드락 방지) + List sorted = commands.stream() + .sorted(Comparator.comparing(OrderItemCommand::productId)) + .toList(); + + Money totalAmount = Money.ZERO; + List snapshots = new ArrayList<>(); + + for (OrderItemCommand cmd : sorted) { + ProductModel product = productService.getProduct(cmd.productId()); + Money subtotal = product.price().multiply(cmd.quantity()); + totalAmount = totalAmount.add(subtotal); + snapshots.add(new SnapshotHolder( + product.getId(), product.name(), product.price(), cmd.quantity() + )); + } + + // 2. 비관적 락: 재고 차감 (productId 오름차순 — 데드락 방지) + for (OrderItemCommand cmd : sorted) { + StockModel stock = stockService.getByProductIdForUpdate(cmd.productId()); + stock.decrease(cmd.quantity()); + } + + // 3. 비관적 락: 쿠폰 검증 + 할인 + 사용 처리 (한 번만 FOR UPDATE로 조회) + Money discountAmount = Money.ZERO; + CouponIssueModel couponIssue = null; + if (couponIssueId != null) { + couponIssue = couponIssueService.getCouponIssueForUpdate(couponIssueId); + couponIssue.validateOwner(userId); + CouponModel coupon = couponService.getCoupon(couponIssue.couponId()); + coupon.validateUsable(totalAmount); + discountAmount = coupon.calculateDiscount(totalAmount); + couponIssue.use(null); + } + + // 4. 주문 생성 + OrderModel order = orderService.save(new OrderModel(userId, totalAmount, discountAmount, couponIssueId)); + + // 5. 쿠폰에 orderId 연결 + if (couponIssue != null) { + couponIssue.setOrderId(order.getId()); + } + + List items = snapshots.stream() + .map(s -> new OrderItemModel( + order.getId(), s.productId(), s.productName(), s.productPrice(), s.quantity() + )) + .toList(); + + List savedItems = orderService.saveAllItems(items); + + return OrderResult.of(order, savedItems); + } + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long orderId, Long userId) { + OrderModel order = orderService.getOrder(orderId, userId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + List orders = orderService.getOrdersByUser(userId, startAt, endAt); + return orders.stream().map(OrderInfo::summaryFrom).toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderForAdmin(Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + Page orders = orderService.getAllForAdmin(pageable); + return orders.map(OrderInfo::summaryFrom); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..e6e14e3a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,49 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + String status, + int totalAmount, + int discountAmount, + int finalAmount, + List items, + ZonedDateTime createdAt +) { + + public static OrderInfo from(OrderModel order, List items) { + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), order.discountAmount().value(), + order.finalAmount().value(), itemInfos, order.getCreatedAt() + ); + } + + public static OrderInfo summaryFrom(OrderModel order) { + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), order.discountAmount().value(), + order.finalAmount().value(), List.of(), order.getCreatedAt() + ); + } + + public record OrderItemInfo( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..58c8ef391 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, int quantity) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java new file mode 100644 index 000000000..56af5b560 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -0,0 +1,16 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.util.List; + +public record OrderResult( + OrderModel order, + List items +) { + + public static OrderResult of(OrderModel order, List items) { + return new OrderResult(order, items); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..db03fc5a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,66 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional + public List saveAllItems(List orderItems) { + return orderItemRepository.saveAll(orderItems); + } + + @Transactional(readOnly = true) + public OrderModel getOrder(Long orderId, Long userId) { + OrderModel order = findById(orderId); + order.validateOwner(userId); + return order; + } + + @Transactional(readOnly = true) + public OrderModel getOrderForAdmin(Long orderId) { + return findById(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + private OrderModel findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java new file mode 100644 index 000000000..d2dd60302 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockStatus; + +import java.time.ZonedDateTime; + +public record ProductDetail( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus stockStatus, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), stockStatus, 0, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } + + public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), null, stockQuantity, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..0b8eb307b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,86 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.application.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.application.stock.StockService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final StockService stockService; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + brandService.getBrand(brandId); + ProductModel product = productService.register(name, description, price, brandId); + stockService.create(product.getId(), initialStock); + return product; + } + + @Transactional(readOnly = true) + public ProductDetail getProduct(Long productId) { + ProductModel product = productService.getProduct(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + } + + @Transactional(readOnly = true) + public ProductDetail getProductForAdmin(Long productId) { + ProductModel product = productService.getProductForAdmin(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productService.getProducts(brandId, sortType, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + }); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + Page products = productService.getProductsForAdmin(brandId, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + }); + } + + @Transactional + public ProductDetail update(Long productId, String name, String description, Money price) { + productService.update(productId, name, description, price); + return getProductForAdmin(productId); + } + + public void delete(Long productId) { + productService.delete(productId); + } + + private String getBrandName(Long brandId) { + try { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return brand.name().value(); + } catch (Exception e) { + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..57204d519 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,81 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; + +public class ProductInfo { + + public enum StockStatus { + IN_STOCK, LOW_STOCK, OUT_OF_STOCK; + + public static StockStatus from(int quantity) { + if (quantity <= 0) return OUT_OF_STOCK; + if (quantity <= 5) return LOW_STOCK; + return IN_STOCK; + } + } + + public record Summary( + Long id, String name, int price, String brandName, StockStatus stockStatus + ) { + public static Summary from(ProductModel product, BrandModel brand, StockModel stock) { + return new Summary( + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + StockStatus.from(stock.getQuantity()) + ); + } + } + + public record Detail( + Long id, String name, String description, int price, + String brandName, int likeCount, StockStatus stockStatus + ) { + public static Detail from(ProductModel product, BrandModel brand, StockModel stock) { + return new Detail( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice().value(), + brand.getName(), + product.getLikeCount(), + StockStatus.from(stock.getQuantity()) + ); + } + } + + public record AdminSummary( + Long id, String name, int price, String brandName, int stockQuantity + ) { + public static AdminSummary from(ProductModel product, BrandModel brand, StockModel stock) { + return new AdminSummary( + product.getId(), + product.getName(), + product.getPrice().value(), + brand.getName(), + stock.getQuantity() + ); + } + } + + public record AdminDetail( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, int stockQuantity + ) { + public static AdminDetail from(ProductModel product, BrandModel brand, StockModel stock) { + return new AdminDetail( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice().value(), + brand.getId(), + brand.getName(), + product.getLikeCount(), + stock.getQuantity() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..18b5d04c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,87 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId) { + ProductModel product = new ProductModel(name, description, price, brandId); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductModel getById(Long id) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. [id = " + id + "]")); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "삭제된 상품입니다. [id = " + id + "]"); + } + return product; + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable, ProductSortType sortType) { + return productRepository.findAll(pageable, sortType); + } + + @Transactional + public ProductModel update(Long id, String name, String description, Money price) { + ProductModel product = getById(id); + product.update(name, description, price); + return product; + } + + @Transactional + public void delete(Long id) { + ProductModel product = getById(id); + product.delete(); + } + + @Transactional + public void softDeleteByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); + } + + @Transactional(readOnly = true) + public Map getProductsByIds(List productIds) { + return productRepository.findAllByIdInAndDeletedAtIsNull(productIds) + .stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + } + + @Transactional + public void incrementLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + @Transactional + public void decrementLikeCount(Long productId) { + productRepository.decrementLikeCount(productId); + } + + private ProductModel findById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java new file mode 100644 index 000000000..c9056c2d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -0,0 +1,43 @@ +package com.loopers.application.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class StockService { + + private final StockRepository stockRepository; + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다. [productId = " + productId + "]")); + } + + @Transactional + public StockModel save(Long productId, int quantity) { + return stockRepository.save(new StockModel(productId, quantity)); + } + + public StockModel getByProductIdForUpdate(Long productId) { + return stockRepository.findByProductIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Map getByProductIds(List productIds) { + return stockRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..6e4cc110b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import com.loopers.interfaces.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AdminUserArgumentResolver adminUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + resolvers.add(adminUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..26fbef7bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,41 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + protected BrandModel() {} + + public BrandModel(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + public String getName() { return name; } + public String getDescription() { return description; } + + public void update(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java new file mode 100644 index 000000000..1e26b649a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java @@ -0,0 +1,41 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandName { + + @Column(name = "name", nullable = false, unique = true) + private String value; + + public BrandName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BrandName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} 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..9f8204caa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + Optional findById(Long id); + Optional findByName(String name); + Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java new file mode 100644 index 000000000..c395ca09e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueModel.java @@ -0,0 +1,109 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon_issue", uniqueConstraints = { + @UniqueConstraint(name = "uk_coupon_issue_user_coupon", columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueModel extends BaseEntity { + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Column(name = "order_id") + private Long orderId; + + protected CouponIssueModel() { + } + + public CouponIssueModel(Long couponId, Long userId, LocalDateTime expiredAt) { + this.couponId = couponId; + this.userId = userId; + this.expiredAt = expiredAt; + guard(); + } + + @Override + protected void guard() { + if (couponId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 ID는 필수입니다."); + } + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료일은 필수입니다."); + } + } + + public void use(Long orderId) { + if (!isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용할 수 없는 쿠폰입니다."); + } + this.usedAt = LocalDateTime.now(); + this.orderId = orderId; + } + + public boolean isUsed() { + return usedAt != null; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiredAt); + } + + public boolean isAvailable() { + return !isUsed() && !isExpired(); + } + + public CouponIssueStatus status() { + if (isUsed()) return CouponIssueStatus.USED; + if (isExpired()) return CouponIssueStatus.EXPIRED; + return CouponIssueStatus.AVAILABLE; + } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 쿠폰만 사용할 수 있습니다."); + } + } + + public Long couponId() { + return couponId; + } + + public Long userId() { + return userId; + } + + public LocalDateTime expiredAt() { + return expiredAt; + } + + public LocalDateTime usedAt() { + return usedAt; + } + + public Long orderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java new file mode 100644 index 000000000..8ce535ffd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.coupon; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface CouponIssueRepository { + + CouponIssueModel save(CouponIssueModel couponIssue); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findAllByUserId(Long userId); + + Page findAllByCouponId(Long couponId, Pageable pageable); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java new file mode 100644 index 000000000..14997b2ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueStatus { + AVAILABLE, USED, EXPIRED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java new file mode 100644 index 000000000..808c66a36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java @@ -0,0 +1,112 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon") +public class CouponModel extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "value", nullable = false) + private int value; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "min_order_amount")) + private Money minOrderAmount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + protected CouponModel() { + } + + public CouponModel(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + guard(); + } + + @Override + protected void guard() { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 필수입니다."); + } + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 값은 0보다 커야 합니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료일은 필수입니다."); + } + } + + public Money calculateDiscount(Money orderAmount) { + return this.type.calculateDiscount(this.value, orderAmount); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } + + public boolean meetsMinOrderAmount(Money orderAmount) { + if (this.minOrderAmount == null) { + return true; + } + return orderAmount.value() >= this.minOrderAmount.value(); + } + + public void validateUsable(Money orderAmount) { + if (isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + if (!meetsMinOrderAmount(orderAmount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "최소 주문 금액 조건을 충족하지 않습니다."); + } + } + + public void update(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + guard(); + } + + public String name() { + return name; + } + + public CouponType type() { + return type; + } + + public int value() { + return value; + } + + public Money minOrderAmount() { + return minOrderAmount; + } + + public LocalDateTime expiredAt() { + return expiredAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..5a258b8de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.coupon; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface CouponRepository { + + CouponModel save(CouponModel coupon); + + Optional findById(Long id); + + Page findAll(Pageable pageable); + + void delete(CouponModel coupon); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..b9d482bc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,20 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.product.Money; + +public enum CouponType { + FIXED { + @Override + public Money calculateDiscount(int value, Money orderAmount) { + return new Money(value).min(orderAmount); + } + }, + RATE { + @Override + public Money calculateDiscount(int value, Money orderAmount) { + return orderAmount.multiply(value).divide(100).min(orderAmount); + } + }; + + public abstract Money calculateDiscount(int value, Money orderAmount); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..d8cbe4a6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "likes") +public class LikeModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected LikeModel() {} + + public LikeModel(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다."); + } + } + + public Long userId() { + return userId; + } + + public Long productId() { + return 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..e84f658f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeRepository { + + LikeModel save(LikeModel like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java new file mode 100644 index 000000000..f5c1b7103 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java @@ -0,0 +1,11 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +/** + * 좋아요 토글 결과. + * @param newLike 새로 생성된 LikeModel (저장 필요), 기존 엔티티 변경만 발생한 경우 empty + * @param countChanged likeCount 변경이 필요한지 여부 + */ +public record LikeResult(Optional newLike, boolean countChanged) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java new file mode 100644 index 000000000..421e6b251 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeToggleService.java @@ -0,0 +1,43 @@ +package com.loopers.domain.like; + +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 좋아요 토글 도메인 서비스. + * Like 엔티티의 상태를 종합하여 좋아요 반응을 결정한다. + * + * 인프라(Repository, DB) 의존 없이 순수 비즈니스 의사결정만 담당. + * likeCount 변경은 호출자가 원자적 업데이트로 처리한다. + */ +@Component +public class LikeToggleService { + + /** + * 좋아요 토글: 상태에 따라 신규 생성 / 복구 / 멱등 무시를 결정한다. + * + * @return LikeResult — 새 LikeModel(저장 필요 여부) + likeCount 변경 여부 + */ + public LikeResult like(Optional existing, Long userId, Long productId) { + if (existing.isEmpty()) { + return new LikeResult(Optional.of(new LikeModel(userId, productId)), true); + } + + LikeModel like = existing.get(); + if (like.getDeletedAt() != null) { + like.restore(); + return new LikeResult(Optional.empty(), true); + } + + // 이미 활성 좋아요 → 멱등 무시 + return new LikeResult(Optional.empty(), false); + } + + /** + * 좋아요 취소: 활성 좋아요를 삭제한다. + */ + public void unlike(LikeModel like) { + like.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java new file mode 100644 index 000000000..948d99b1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + private static final Pattern PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + + @Column(name = "email") + private String value; + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_EMAIL); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java new file mode 100644 index 000000000..0f7da8a33 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + @Column(name = "login_id", nullable = false, unique = true) + private String value; + + public LoginId(String value) { + if (value == null || value.isBlank() || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_LOGIN_ID); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 000000000..e0ffb37cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Column(nullable = false) + private String password; + + @Embedded + private MemberName name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Embedded + private Email email; + + protected MemberModel() {} + + public MemberModel(LoginId loginId, String encodedPassword, MemberName name, + LocalDate birthDate, Email email) { + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public boolean matchesPassword(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.password); + } + + public String encodedPassword() { + return password; + } + + public LoginId loginId() { + return loginId; + } + + public MemberName name() { + return name; + } + + public LocalDate birthDate() { + return birthDate; + } + + public Email email() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java new file mode 100644 index 000000000..7c850b274 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java @@ -0,0 +1,50 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberName { + + private static final char MASK_CHAR = '*'; + + @Column(name = "name", nullable = false) + private String value; + + public MemberName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_NAME); + } + this.value = value; + } + + public String value() { + return value; + } + + public String masked() { + if (value.length() == 1) { + return String.valueOf(MASK_CHAR); + } + return value.substring(0, value.length() - 1) + MASK_CHAR; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} 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..8ed51f87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + MemberModel save(MemberModel member); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java new file mode 100644 index 000000000..dbb4ab6dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java @@ -0,0 +1,52 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + private final String value; + + public static Password of(String value, LocalDate birthDate) { + Password password = new Password(value); + password.validateAgainst(birthDate); + return password; + } + + public Password(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (!ALLOWED_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + this.value = value; + } + + public void validateAgainst(LocalDate birthDate) { + if (birthDate == null) { + return; + } + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String yymmdd = yyyymmdd.substring(2); + if (value.contains(yyyymmdd) || value.contains(yymmdd)) { + throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + public String value() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..8e0b81dc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,83 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "order_item") +public class OrderItemModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "product_price", nullable = false)) + private Money productPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItemModel() { + } + + public OrderItemModel(Long orderId, Long productId, String productName, Money productPrice, int quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + guard(); + } + + @Override + protected void guard() { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 정보는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + if (productPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1 이상이어야 합니다."); + } + } + + public Money subtotal() { + return productPrice.multiply(quantity); + } + + public Long orderId() { + return orderId; + } + + public Long productId() { + return productId; + } + + public String productName() { + return productName; + } + + public Money productPrice() { + return productPrice; + } + + public int quantity() { + return quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..7fce7486b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + OrderItemModel save(OrderItemModel orderItem); + + List saveAll(List orderItems); + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..24ca1d150 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,73 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "discount_amount", nullable = false)) + private Money discountAmount; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "final_amount", nullable = false)) + private Money finalAmount; + + @Column(name = "coupon_issue_id") + private Long couponIssueId; + + protected OrderModel() { + } + + public OrderModel(Long userId, Money totalAmount, Money discountAmount, Long couponIssueId) { + this.userId = userId; + this.status = OrderStatus.CREATED; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.couponIssueId = couponIssueId; + guard(); + this.finalAmount = totalAmount.subtract(discountAmount); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문자 정보는 필수입니다."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 총액은 필수입니다."); + } + if (discountAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 필수입니다."); + } + } + + public Long userId() { return userId; } + public OrderStatus status() { return status; } + public Money totalAmount() { return totalAmount; } + public Money discountAmount() { return discountAmount; } + public Money finalAmount() { return finalAmount; } + public Long couponIssueId() { return couponIssueId; } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..23a12f9bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + OrderModel save(OrderModel order); + + Optional findById(Long id); + + List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ); + + Page findAll(Pageable pageable); +} 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..0ffc53f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + CONFIRMED, + SHIPPING, + DELIVERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..e64ce0ffd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,65 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + public static final Money ZERO = new Money(0); + + @Column(name = "price", nullable = false) + private int value; + + public Money(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + this.value = value; + } + + public int value() { + return value; + } + + public Money multiply(int quantity) { + return new Money(this.value * quantity); + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money divide(int divisor) { + if (divisor == 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0으로 나눌 수 없습니다."); + } + return new Money(this.value / divisor); + } + + public Money subtract(Money other) { + return new Money(Math.max(this.value - other.value, 0)); + } + + public Money min(Money other) { + return this.value <= other.value ? this : other; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money money)) return false; + return value == money.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..4083c2b0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,78 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + @Column(nullable = false) + private String name; + + private String description; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected ProductModel() {} + + public ProductModel(String name, String description, Money price, Long brandId) { + validateName(name); + validatePrice(price); + this.name = name; + this.description = description; + this.price = price; + this.brandId = brandId; + this.likeCount = 0; + } + + public String getName() { return name; } + public String getDescription() { return description; } + public Money getPrice() { return price; } + public Long getBrandId() { return brandId; } + public int getLikeCount() { return likeCount; } + + public void update(String name, String description, Money price) { + validateName(name); + validatePrice(price); + this.name = name; + this.description = description; + this.price = price; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + } + + private void validatePrice(Money price) { + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..2c0eeecfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,21 @@ +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 { + + ProductModel save(ProductModel product); + + int incrementLikeCount(Long productId); + + int decrementLikeCount(Long productId); + + Optional findById(Long id); + List findAllByBrandId(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..d2dc834b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + CREATED_DESC, + PRICE_ASC, + PRICE_DESC, + LIKES_DESC +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java new file mode 100644 index 000000000..d47bc4a6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain.stock; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "stock", uniqueConstraints = @UniqueConstraint(columnNames = "product_id")) +public class StockModel extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(nullable = false) + private int quantity; + + protected StockModel() {} + + public StockModel(Long productId, int quantity) { + validateQuantity(quantity); + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { return productId; } + public int getQuantity() { return quantity; } + + public void decrease(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.quantity -= amount; + } + + public void update(int quantity) { + validateQuantity(quantity); + this.quantity = quantity; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + private void validateQuantity(int quantity) { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..e0c62cf0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.stock; + +import java.util.List; +import java.util.Optional; + +public interface StockRepository { + Optional findByProductId(Long productId); + + Optional findByProductIdForUpdate(Long productId); + + List findAllByProductIdIn(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java new file mode 100644 index 000000000..a02da2a9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java @@ -0,0 +1,13 @@ +package com.loopers.domain.stock; + +public enum StockStatus { + IN_STOCK, + LOW_STOCK, + OUT_OF_STOCK; + + public static StockStatus from(int quantity) { + if (quantity <= 0) return OUT_OF_STOCK; + if (quantity <= 10) return LOW_STOCK; + return IN_STOCK; + } +} 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..e4a3b1a3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByNameValue(String value); + + List findAllByIdIn(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..47139a0d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtIsNull(name); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java new file mode 100644 index 000000000..7bb602357 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CouponIssueJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ci FROM CouponIssueModel ci WHERE ci.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + List findAllByUserId(Long userId); + + Page findAllByCouponId(Long couponId, Pageable pageable); + + Optional findByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java new file mode 100644 index 000000000..6f0a88d89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponIssueRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponIssueRepositoryImpl implements CouponIssueRepository { + + private final CouponIssueJpaRepository couponIssueJpaRepository; + + @Override + public CouponIssueModel save(CouponIssueModel couponIssue) { + return couponIssueJpaRepository.save(couponIssue); + } + + @Override + public Optional findById(Long id) { + return couponIssueJpaRepository.findById(id); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return couponIssueJpaRepository.findByIdForUpdate(id); + } + + @Override + public List findAllByUserId(Long userId) { + return couponIssueJpaRepository.findAllByUserId(userId); + } + + @Override + public Page findAllByCouponId(Long couponId, Pageable pageable) { + return couponIssueJpaRepository.findAllByCouponId(couponId, pageable); + } + + @Override + public Optional findByUserIdAndCouponId(Long userId, Long couponId) { + return couponIssueJpaRepository.findByUserIdAndCouponId(userId, couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..bea7a62e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponJpaRepository extends JpaRepository { + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..1fce579b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public CouponModel save(CouponModel coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public Optional findById(Long id) { + return couponJpaRepository.findById(id); + } + + @Override + public Page findAll(Pageable pageable) { + return couponJpaRepository.findAll(pageable); + } + + @Override + public void delete(CouponModel coupon) { + couponJpaRepository.delete(coupon); + } +} 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..d46e2c3d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + @Query("SELECT l FROM LikeModel l WHERE l.userId = :userId AND l.deletedAt IS NULL " + + "AND EXISTS (SELECT 1 FROM ProductModel p WHERE p.id = l.productId AND p.deletedAt IS NULL)") + Page findActiveLikesWithActiveProduct(@Param("userId") Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..8143351fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +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.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + @Override + public Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable) { + return likeJpaRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} 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..2eb5cfe87 --- /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 com.loopers.domain.member.MemberModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginIdValue(String value); +} 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..5f7f6e00d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginIdValue(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..c091c68d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a39ac4aca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemModel save(OrderItemModel orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} 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..e989b8197 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..d6bb42ca1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +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.Component; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..9d09cdbc4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") + int incrementLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") + int decrementLikeCount(@Param("id") Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(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..a6a1b765a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public int incrementLikeCount(Long productId) { + return productJpaRepository.incrementLikeCount(productId); + } + + @Override + public int decrementLikeCount(Long productId) { + return productJpaRepository.decrementLikeCount(productId); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public Page findAll(Pageable pageable, ProductSortType sortType) { + Sort sort = toSort(sortType); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); + } + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + private Sort toSort(ProductSortType sortType) { + return switch (sortType) { + case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); + case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + }; + } + + @Override + public List findAllByIdInAndDeletedAtIsNull(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..2a811bf5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StockJpaRepository extends JpaRepository { + Optional findByProductId(Long productId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM StockModel s WHERE s.productId = :productId") + Optional findByProductIdForUpdate(@Param("productId") Long productId); + + List findAllByProductIdIn(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..d7242d0c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return stockJpaRepository.findByProductId(productId); + } + + @Override + public Optional findByProductIdForUpdate(Long productId) { + return stockJpaRepository.findByProductIdForUpdate(productId); + } + + @Override + public List findAllByProductIdIn(List productIds) { + return stockJpaRepository.findAllByProductIdIn(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..638f37300 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,12 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String message = String.format("필수 헤더 '%s'이(가) 누락되었습니다.", e.getHeaderName()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java new file mode 100644 index 000000000..742e59d0c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java new file mode 100644 index 000000000..3ebfb8b43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_ADMIN_LDAP = "X-Loopers-AdminLdap"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AdminUser.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public String resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String adminLdap = webRequest.getHeader(HEADER_ADMIN_LDAP); + + if (adminLdap == null || adminLdap.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "관리자 인증 헤더가 누락되었습니다."); + } + + return adminLdap; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java new file mode 100644 index 000000000..0533de4d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..d746e275d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberAuthService memberAuthService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class) + && parameter.getParameterType().equals(MemberModel.class); + } + + @Override + public MemberModel resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String loginPw = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); + } + + return memberAuthService.authenticate(loginId, loginPw); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java new file mode 100644 index 000000000..bc887e320 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1ApiSpec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Brand Admin V1 API", description = "브랜드 관리 API") +public interface BrandAdminV1ApiSpec { + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse create(String adminLdap, BrandAdminV1Dto.CreateRequest request); + + @Operation(summary = "브랜드 목록 조회", description = "브랜드 목록을 페이징하여 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable); + + @Operation(summary = "브랜드 상세 조회", description = "브랜드 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long brandId); + + @Operation(summary = "브랜드 수정", description = "브랜드 정보를 수정합니다.") + ApiResponse update(String adminLdap, Long brandId, BrandAdminV1Dto.UpdateRequest request); + + @Operation(summary = "브랜드 삭제", description = "브랜드를 삭제합니다. 해당 브랜드의 상품도 함께 삭제됩니다.") + ApiResponse delete(String adminLdap, Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..07d6d8821 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller implements BrandAdminV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse create( + @AdminUser String adminLdap, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable + ) { + Page response = brandFacade.getAll(pageable) + .map(BrandAdminV1Dto.BrandResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId + ) { + BrandInfo info = brandFacade.getById(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete( + @AdminUser String adminLdap, + @PathVariable(value = "brandId") Long brandId + ) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java new file mode 100644 index 000000000..8a22e5d60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandAdminV1Dto { + + public record CreateRequest(String name, String description) {} + + public record UpdateRequest(String name, String description) {} + + public record BrandResponse(Long id, String name, String description) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..92a9ea7e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..107c8ca6c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java new file mode 100644 index 000000000..680103f80 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result = brandFacade.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { + BrandInfo info = brandFacade.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java new file mode 100644 index 000000000..7ae7e03fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandInfo; + +import java.time.ZonedDateTime; + +public class BrandAdminV1Dto { + + public record CreateRequest( + String name, + String description + ) {} + + public record UpdateRequest( + String name, + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), + info.name(), + info.description(), + info.createdAt(), + info.updatedAt(), + info.deletedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java new file mode 100644 index 000000000..ea9294343 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +public class CouponAdminV1Controller { + + private final CouponService couponService; + private final CouponIssueService couponIssueService; + + @GetMapping("/api-admin/v1/coupons") + public ApiResponse> getCoupons( + @AdminUser AdminInfo admin, + Pageable pageable + ) { + Page coupons = couponService.getAllCoupons(pageable); + return ApiResponse.success(coupons.map(CouponAdminV1Dto.CouponResponse::from)); + } + + @GetMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse getCoupon( + @AdminUser AdminInfo admin, + @PathVariable Long couponId + ) { + CouponModel coupon = couponService.getCoupon(couponId); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @PostMapping("/api-admin/v1/coupons") + public ApiResponse createCoupon( + @AdminUser AdminInfo admin, + @RequestBody CouponAdminV1Dto.CreateRequest request + ) { + CouponModel coupon = couponService.create( + request.name(), request.type(), request.value(), + request.toMinOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @PutMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse updateCoupon( + @AdminUser AdminInfo admin, + @PathVariable Long couponId, + @RequestBody CouponAdminV1Dto.UpdateRequest request + ) { + CouponModel coupon = couponService.update( + couponId, request.name(), request.type(), request.value(), + request.toMinOrderAmount(), request.expiredAt() + ); + return ApiResponse.success(CouponAdminV1Dto.CouponResponse.from(coupon)); + } + + @DeleteMapping("/api-admin/v1/coupons/{couponId}") + public ApiResponse deleteCoupon( + @AdminUser AdminInfo admin, + @PathVariable Long couponId + ) { + couponService.delete(couponId); + return ApiResponse.success(null); + } + + @GetMapping("/api-admin/v1/coupons/{couponId}/issues") + public ApiResponse> getCouponIssues( + @AdminUser AdminInfo admin, + @PathVariable Long couponId, + Pageable pageable + ) { + return ApiResponse.success( + couponIssueService.getIssuesByCoupon(couponId, pageable) + .map(CouponV1Dto.CouponIssueResponse::from) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java new file mode 100644 index 000000000..87aee64ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Dto.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; + +import java.time.LocalDateTime; + +public class CouponAdminV1Dto { + + public record CreateRequest( + String name, + CouponType type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public Money toMinOrderAmount() { + return minOrderAmount != null ? new Money(minOrderAmount) : null; + } + } + + public record UpdateRequest( + String name, + CouponType type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public Money toMinOrderAmount() { + return minOrderAmount != null ? new Money(minOrderAmount) : null; + } + } + + public record CouponResponse( + Long id, + String name, + String type, + int value, + Integer minOrderAmount, + LocalDateTime expiredAt + ) { + public static CouponResponse from(CouponModel coupon) { + return new CouponResponse( + coupon.getId(), + coupon.name(), + coupon.type().name(), + coupon.value(), + coupon.minOrderAmount() != null ? coupon.minOrderAmount().value() : null, + coupon.expiredAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java new file mode 100644 index 000000000..cbbe0042e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class CouponV1Controller { + + private final CouponService couponService; + private final CouponIssueService couponIssueService; + + @PostMapping("/api/v1/coupons/{couponId}/issue") + public ApiResponse issueCoupon( + @LoginMember MemberModel member, + @PathVariable Long couponId + ) { + CouponModel coupon = couponService.getCoupon(couponId); + CouponIssueModel issue = couponIssueService.issue(coupon, member.getId()); + return ApiResponse.success(CouponV1Dto.CouponIssueResponse.from(issue)); + } + + @GetMapping("/api/v1/users/me/coupons") + public ApiResponse> getMyCoupons( + @LoginMember MemberModel member + ) { + List issues = couponIssueService.getMyIssues(member.getId()); + List response = issues.stream() + .map(CouponV1Dto.CouponIssueResponse::from) + .toList(); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java new file mode 100644 index 000000000..590607135 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.CouponIssueModel; + +import java.time.LocalDateTime; + +public class CouponV1Dto { + + public record CouponIssueResponse( + Long id, + Long couponId, + String status, + LocalDateTime usedAt, + Long orderId, + LocalDateTime expiredAt + ) { + public static CouponIssueResponse from(CouponIssueModel issue) { + return new CouponIssueResponse( + issue.getId(), + issue.couponId(), + issue.status().name(), + issue.usedAt(), + issue.orderId(), + issue.expiredAt() + ); + } + } +} 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 index 917376016..c9cfa6e8d 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.example; -import com.loopers.application.example.ExampleFacade; import com.loopers.application.example.ExampleInfo; +import com.loopers.application.example.ExampleService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -14,14 +14,14 @@ @RequestMapping("/api/v1/examples") public class ExampleV1Controller implements ExampleV1ApiSpec { - private final ExampleFacade exampleFacade; + private final ExampleService exampleService; @GetMapping("/{exampleId}") @Override public ApiResponse getExample( @PathVariable(value = "exampleId") Long exampleId ) { - ExampleInfo info = exampleFacade.getExample(exampleId); + ExampleInfo info = exampleService.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/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..875917550 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Like V1 API", description = "좋아요 API") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse register(MemberModel member, Long productId); + + @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") + ApiResponse cancel(MemberModel member, Long productId); + + @Operation(summary = "내 좋아요 목록 조회", description = "내 좋아요 목록을 조회합니다.") + ApiResponse> getMyLikes(MemberModel member, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..da90d2f46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeWithProduct; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse like(@LoginMember MemberModel member, @PathVariable Long productId) { + likeFacade.like(member.getId(), productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse unlike(@LoginMember MemberModel member, @PathVariable Long productId) { + likeFacade.unlike(member.getId(), productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getMyLikes( + @LoginMember MemberModel member, + @PathVariable Long userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + if (!member.getId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 좋아요 목록만 조회할 수 있습니다."); + } + Page likes = likeFacade.getMyLikesWithProducts(member.getId(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + return ApiResponse.success(likes.map(LikeV1Dto.LikeResponse::from)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..157541905 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeWithProduct; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeWithProduct lwp) { + return new LikeResponse( + lwp.likeId(), + lwp.productId(), + lwp.productName(), + lwp.productPrice(), + lwp.likedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..625df737d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import static com.loopers.interfaces.api.member.MemberV1Dto.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + ApiResponse signup(SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "헤더 인증을 통해 내 정보를 조회합니다.") + ApiResponse getMe(MemberModel member); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword(MemberModel member, ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..bcdcfd412 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @Override + public ApiResponse signup( + @RequestBody MemberV1Dto.SignupRequest request + ) { + MemberInfo info = memberFacade.signup( + request.loginId(), request.password(), request.name(), + request.birthDate(), request.email() + ); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe(@LoginMember MemberModel member) { + MemberInfo info = memberFacade.getMyInfo(member); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @PutMapping("/password") + @Override + public ApiResponse changePassword( + @LoginMember MemberModel member, + @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(member, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..9ae0821d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record MemberResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MemberResponse from(MemberInfo info) { + return new MemberResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java new file mode 100644 index 000000000..abdfde0c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Order Admin V1 API", description = "주문 관리 API") +public interface OrderAdminV1ApiSpec { + + @Operation(summary = "주문 목록 조회 (관리자)", description = "전체 주문 목록을 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable); + + @Operation(summary = "주문 상세 조회 (관리자)", description = "주문 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..fcfcc414a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller implements OrderAdminV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable + ) { + Page response = orderFacade.getAllOrders(pageable) + .map(OrderAdminV1Dto.OrderAdminSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "orderId") Long orderId + ) { + OrderInfo.Detail info = orderFacade.getDetailForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java new file mode 100644 index 000000000..a77501ea1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderAdminSummaryResponse( + Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt + ) { + public static OrderAdminSummaryResponse from(OrderInfo.Summary info) { + return new OrderAdminSummaryResponse( + info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + ); + } + } + + public record OrderAdminDetailResponse( + Long id, Long memberId, int totalAmount, String status, + List orderItems, ZonedDateTime createdAt + ) { + public static OrderAdminDetailResponse from(OrderInfo.Detail info) { + List items = info.orderItems().stream() + .map(OrderV1Dto.OrderItemResponse::from) + .toList(); + return new OrderAdminDetailResponse( + info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..21c758e17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Order V1 API", description = "고객 주문 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") + ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateOrderRequest request); + + @Operation(summary = "내 주문 목록 조회", description = "내 주문 목록을 조회합니다.") + ApiResponse> getMyOrders(MemberModel member, Pageable pageable); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다.") + ApiResponse getById(MemberModel member, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..385550781 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + @LoginMember MemberModel member, + @RequestBody OrderV1Dto.CreateRequest request + ) { + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands(), request.couponIssueId()); + return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse> getMyOrders( + @LoginMember MemberModel member, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt + ) { + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + List orders = orderFacade.getOrdersByUser(member.getId(), start, end); + List response = orders.stream() + .map(OrderV1Dto.OrderSummaryResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + @LoginMember MemberModel member, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..edb40eb58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,90 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest(List items, Long couponIssueId) { + + public List toCommands() { + return items.stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest(Long productId, int quantity) { + } + + public record OrderResponse( + Long orderId, + String status, + int totalAmount, + int discountAmount, + int finalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.status(), info.totalAmount(), + info.discountAmount(), info.finalAmount(), items, info.createdAt() + ); + } + + public static OrderResponse fromResult(OrderResult result) { + List items = result.items().stream() + .map(item -> new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + )) + .toList(); + return new OrderResponse( + result.order().getId(), result.order().status().name(), + result.order().totalAmount().value(), + result.order().discountAmount().value(), + result.order().finalAmount().value(), + items, result.order().getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.status(), info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java new file mode 100644 index 000000000..1067c8535 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); + return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @AdminUser AdminInfo admin, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.getOrderForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java new file mode 100644 index 000000000..a7c275e62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), items, info.createdAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..3453e5cdc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product Admin V1 API", description = "상품 관리 API") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다.") + ApiResponse create(String adminLdap, ProductAdminV1Dto.CreateRequest request); + + @Operation(summary = "상품 목록 조회 (관리자)", description = "관리자용 상품 목록을 조회합니다.") + ApiResponse> getAll(String adminLdap, Pageable pageable, String sortType); + + @Operation(summary = "상품 상세 조회 (관리자)", description = "관리자용 상품 상세 정보를 조회합니다.") + ApiResponse getById(String adminLdap, Long productId); + + @Operation(summary = "상품 수정", description = "상품 정보를 수정합니다.") + ApiResponse update(String adminLdap, Long productId, ProductAdminV1Dto.UpdateRequest request); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse delete(String adminLdap, Long productId); + + @Operation(summary = "재고 수정", description = "상품 재고를 수정합니다.") + ApiResponse updateStock(String adminLdap, Long productId, ProductAdminV1Dto.UpdateStockRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..c348c5076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,99 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse create( + @AdminUser String adminLdap, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + ProductInfo.AdminDetail info = productFacade.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.stockQuantity() + ); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse> getAll( + @AdminUser String adminLdap, + Pageable pageable, + @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType + ) { + ProductSortType sort = ProductSortType.valueOf(sortType); + Page response = productFacade.getAllForAdmin(pageable, sort) + .map(ProductAdminV1Dto.ProductAdminSummaryResponse::from); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId + ) { + ProductInfo.AdminDetail info = productFacade.getDetailForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductInfo.AdminDetail info = productFacade.update( + productId, request.name(), request.description(), new Money(request.price()) + ); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId + ) { + productFacade.delete(productId); + return ApiResponse.success(); + } + + @PatchMapping("/{productId}/stock") + @Override + public ApiResponse updateStock( + @AdminUser String adminLdap, + @PathVariable(value = "productId") Long productId, + @RequestBody ProductAdminV1Dto.UpdateStockRequest request + ) { + ProductInfo.AdminDetail info = productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..2abdd5f52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +public class ProductAdminV1Dto { + + public record CreateRequest(String name, String description, int price, Long brandId, int stockQuantity) {} + + public record UpdateRequest(String name, String description, int price) {} + + public record UpdateStockRequest(int quantity) {} + + public record ProductAdminSummaryResponse( + Long id, String name, int price, String brandName, int stockQuantity + ) { + public static ProductAdminSummaryResponse from(ProductInfo.AdminSummary info) { + return new ProductAdminSummaryResponse( + info.id(), info.name(), info.price(), info.brandName(), info.stockQuantity() + ); + } + } + + public record ProductAdminDetailResponse( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, int stockQuantity + ) { + public static ProductAdminDetailResponse from(ProductInfo.AdminDetail info) { + return new ProductAdminDetailResponse( + info.id(), info.name(), info.description(), info.price(), + info.brandId(), info.brandName(), info.likeCount(), info.stockQuantity() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..9876a606f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "고객 상품 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 페이징하여 조회합니다.") + ApiResponse> getAll(Pageable pageable, String sortType); + + @Operation(summary = "상품 상세 조회", description = "상품 상세 정보를 조회합니다.") + ApiResponse getById(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..71e87e49d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSortType sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); + return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductDetail detail = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..b0a96c407 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetail; +import com.loopers.domain.stock.StockStatus; + +public class ProductV1Dto { + + public record ProductSummaryResponse( + Long id, String name, int price, String brandName, String stockStatus + ) { + public static ProductResponse from(ProductDetail detail) { + return new ProductResponse( + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockStatus() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java new file mode 100644 index 000000000..341540a36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Money; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + var product = productFacade.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.initialStock() + ); + ProductDetail detail = productFacade.getProductForAdmin(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long brandId + ) { + Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); + return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @AdminUser AdminInfo admin, + @PathVariable Long productId + ) { + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java new file mode 100644 index 000000000..5f58c5cb2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.application.product.ProductDetail; + +import java.time.ZonedDateTime; + +public class ProductAdminV1Dto { + + public record CreateRequest( + String name, + String description, + int price, + Long brandId, + int initialStock + ) {} + + public record UpdateRequest( + String name, + String description, + int price + ) {} + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ProductResponse from(ProductDetail detail) { + return new ProductResponse( + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockQuantity(), + detail.createdAt(), + detail.updatedAt(), + detail.deletedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java new file mode 100644 index 000000000..516f8b1d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record AdminInfo(String ldap) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java new file mode 100644 index 000000000..3cf3df48e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java new file mode 100644 index 000000000..969bd5d7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String VALID_LDAP = "loopers.admin"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AdminUser.class) + && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String ldap = webRequest.getHeader("X-Loopers-Ldap"); + + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "어드민 인증 헤더가 누락되었습니다."); + } + + if (!VALID_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 어드민 인증입니다."); + } + + return new AdminInfo(ldap); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java new file mode 100644 index 000000000..93ea3e09a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..7b8ccac5e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.auth; + +import com.loopers.application.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberAuthService memberAuthService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class) + && MemberModel.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String loginId = webRequest.getHeader("X-Loopers-LoginId"); + String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); + + if (loginId == null || loginPw == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "인증 헤더가 누락되었습니다."); + } + + return memberAuthService.authenticate(loginId, loginPw); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..2c55138ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,8 +10,21 @@ 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(), "이미 존재하는 리소스입니다."), + + /** Member 도메인 에러 */ + INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "Invalid Login Id", "로그인 ID는 영문과 숫자만 가능합니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호가 규칙에 맞지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "Invalid Email", "이메일 형식이 올바르지 않습니다."), + INVALID_NAME(HttpStatus.BAD_REQUEST, "Invalid Name", "이름은 필수입니다."), + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login Id", "이미 존재하는 로그인 ID입니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "현재 비밀번호가 일치하지 않습니다."), + PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "Password Same As Old", "현재 비밀번호는 사용할 수 없습니다."), + PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "Password Contains Birth Date", "비밀번호에 생년월일을 포함할 수 없습니다."), + MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member Not Found", "회원을 찾을 수 없습니다."), + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "Authentication Failed", "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java new file mode 100644 index 000000000..d2543dca5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java @@ -0,0 +1,205 @@ +package com.loopers.application; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; +import com.loopers.application.like.LikeFacade; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductService; +import com.loopers.application.stock.StockService; +import com.loopers.domain.coupon.CouponIssueModel; +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.product.Money; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ConcurrencyIntegrationTest { + + @Autowired private OrderFacade orderFacade; + @Autowired private LikeFacade likeFacade; + @Autowired private ProductFacade productFacade; + @Autowired private BrandService brandService; + @Autowired private ProductService productService; + @Autowired private StockService stockService; + @Autowired private CouponService couponService; + @Autowired private CouponIssueService couponIssueService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand() { return brandService.register("테스트브랜드", "설명").getId(); } + private Long createProduct(Long brandId, int stock) { + return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).getId(); + } + + @DisplayName("재고 동시성") + @Nested + class StockConcurrency { + + @DisplayName("10명이 동시에 1개씩 주문해도 재고가 정확히 차감된다") + @Test + void stockDecreasedCorrectlyUnderConcurrency() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 100); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), null); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(10); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(90); + } + + @DisplayName("재고 5개인 상품에 10명이 동시 주문하면 5명만 성공한다") + @Test + void onlyAvailableStockSucceeds() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 5); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), null); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(5); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(0); + } + } + + @DisplayName("쿠폰 동시성") + @Nested + class CouponConcurrency { + + @DisplayName("동일 쿠폰으로 여러 기기에서 동시 주문해도 1번만 사용된다") + @Test + void couponUsedOnlyOnce() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 100); + CouponModel coupon = couponService.create("테스트쿠폰", CouponType.FIXED, 1000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + Long userId = 1L; + CouponIssueModel issue = couponIssueService.issue(coupon, userId); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + orderFacade.placeOrder(userId, + List.of(new OrderItemCommand(productId, 1)), issue.getId()); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(1); + } + } + + @DisplayName("좋아요 동시성") + @Nested + class LikeConcurrency { + + @DisplayName("10명이 동시에 좋아요해도 좋아요 수가 정확하다") + @Test + void likeCountAccurateUnderConcurrency() throws InterruptedException { + // given + Long brandId = createBrand(); + Long productId = createProduct(brandId, 10); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + // when + for (int i = 0; i < threadCount; i++) { + long userId = i + 1; + executor.submit(() -> { + try { + likeFacade.like(userId, productId); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(10); + assertThat(productService.getProduct(productId).likeCount()).isEqualTo(10); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..c16164ffa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,49 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductService; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + private BrandFacade brandFacade; + + @BeforeEach + void setUp() { + brandFacade = new BrandFacade(brandService, productService); + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("브랜드 soft delete 후 소속 상품을 연쇄 soft delete 한다") + @Test + void deletesBrandAndCascadesProducts() { + // given + Long brandId = 1L; + + // when + brandFacade.delete(brandId); + + // then + verify(brandService).delete(brandId); + verify(productService).deleteAllByBrandId(brandId); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..dca5344fd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,209 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면 브랜드가 생성된다") + @Test + void createsBrandSuccessfully() { + // given & when + BrandModel result = brandService.register("나이키", "스포츠 브랜드"); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("스포츠 브랜드") + ); + } + + @DisplayName("중복 브랜드명이면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateName() { + // given + brandService.register("나이키", "스포츠 브랜드"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register("나이키", "다른 설명")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetBrand { + + @DisplayName("존재하고 미삭제 상태면 브랜드를 반환한다") + @Test + void returnsBrand() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.getBrand(saved.getId()); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("삭제된 브랜드면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenDeleted() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 변경할 수 있다") + @Test + void updatesSuccessfully() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.update(saved.getId(), "뉴발란스", "라이프스타일 브랜드"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), + () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + ); + } + + @DisplayName("동일명 유지 시 중복 체크를 통과한다") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + BrandModel result = brandService.update(saved.getId(), "나이키", "설명만 변경"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("설명만 변경") + ); + } + + @DisplayName("다른 이름으로 변경 시 중복이면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateNameChange() { + // given + brandService.register("나이키", "스포츠 브랜드"); + BrandModel target = brandService.register("아디다스", "다른 브랜드"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(target.getId(), "나이키", "변경 시도")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("soft delete 후 customer 조회에서 제외된다") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + brandService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete 후 admin 조회에서는 포함된다") + @Test + void includedInAdminQueryAfterDelete() { + // given + BrandModel saved = brandService.register("나이키", "스포츠 브랜드"); + + // when + brandService.delete(saved.getId()); + + // then + BrandModel result = brandService.getBrandForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetAll { + + @DisplayName("페이징된 결과를 반환한다") + @Test + void returnsPagedResult() { + // given + brandService.register("나이키", "스포츠"); + brandService.register("아디다스", "스포츠"); + brandService.register("뉴발란스", "라이프스타일"); + + // when + Page result = brandService.getAll(PageRequest.of(0, 2)); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(3), + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getTotalPages()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java new file mode 100644 index 000000000..2ff3c8645 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -0,0 +1,212 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +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.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("이름이 중복되지 않으면 정상 등록된다") + @Test + void registersSuccessfully() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + BrandModel brand = new BrandModel(name, description); + given(brandRepository.findByName(name)).willReturn(Optional.empty()); + given(brandRepository.save(any(BrandModel.class))).willReturn(brand); + // act + BrandModel result = brandService.register(name, description); + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getDescription()).isEqualTo(description) + ); + then(brandRepository).should().save(any(BrandModel.class)); + } + + @DisplayName("이름이 중복되면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateName() { + // arrange + String name = "나이키"; + given(brandRepository.findByName(name)).willReturn(Optional.of(new BrandModel(name, "기존"))); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.register(name, "신규"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + then(brandRepository).should(never()).save(any()); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetById { + + @DisplayName("존재하는 ID면 브랜드를 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + // act + BrandModel result = brandService.getById(id); + // assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(brandRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("자기 자신과 같은 이름이면 정상 수정된다") + @Test + void updatesWhenSameNameAsSelf() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + ReflectionTestUtils.setField(brand, "id", id); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + given(brandRepository.findByName("나이키")).willReturn(Optional.of(brand)); + // act + BrandModel result = brandService.update(id, "나이키", "새 설명"); + // assert + assertThat(result.getDescription()).isEqualTo("새 설명"); + } + + @DisplayName("다른 브랜드와 이름이 중복되면 CONFLICT 예외가 발생한다") + @Test + void throwsOnDuplicateNameWithOther() { + // arrange + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠"); + ReflectionTestUtils.setField(brand, "id", id); + BrandModel other = new BrandModel("아디다스", "독일"); + ReflectionTestUtils.setField(other, "id", 2L); + given(brandRepository.findById(id)).willReturn(Optional.of(brand)); + given(brandRepository.findByName("아디다스")).willReturn(Optional.of(other)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.update(id, "아디다스", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 soft delete 한다") + @Test + void softDeletesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + brandService.delete(brandId); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.delete(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetAll { + + @DisplayName("페이징된 결과를 반환한다") + @Test + void returnsPagedResult() { + // given + Pageable pageable = PageRequest.of(0, 10); + List brands = List.of( + new BrandModel(new BrandName("나이키"), "스포츠"), + new BrandModel(new BrandName("아디다스"), "스포츠") + ); + Page page = new PageImpl<>(brands, pageable, brands.size()); + when(brandRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = brandService.getAll(pageable); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).hasSize(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java similarity index 67% rename from apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java index bbd5fdbe1..db0986ed8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/example/ExampleServiceIntegrationTest.java @@ -1,5 +1,6 @@ -package com.loopers.domain.example; +package com.loopers.application.example; +import com.loopers.domain.example.ExampleModel; import com.loopers.infrastructure.example.ExampleJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -31,34 +32,34 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("예시를 조회할 때,") + @DisplayName("예시 조회") @Nested class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @DisplayName("존재하는 ID면 해당 예시 정보를 반환한다") @Test - void returnsExampleInfo_whenValidIdIsProvided() { + void returnsExampleForExistingId() { // arrange ExampleModel exampleModel = exampleJpaRepository.save( new ExampleModel("예시 제목", "예시 설명") ); // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); + ExampleInfo 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()) + () -> assertThat(result.id()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.name()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.description()).isEqualTo(exampleModel.getDescription()) ); } - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") @Test - void throwsException_whenInvalidIdIsProvided() { + void throwsOnNonExistentId() { // arrange - Long invalidId = 999L; // Assuming this ID does not exist + Long invalidId = 999L; // act CoreException exception = assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java new file mode 100644 index 000000000..afee36977 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -0,0 +1,202 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.brand.BrandService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class LikeFacadeIntegrationTest { + + @Autowired private LikeFacade likeFacade; + @Autowired private ProductFacade productFacade; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } + private Long createProduct(String name, int price, Long brandId) { + return productFacade.register(name, "설명", new Money(price), brandId, 10).getId(); + } + + @DisplayName("좋아요 등록") + @Nested + class Like { + + @DisplayName("상품에 좋아요를 등록하면 likeCount가 증가한다") + @Test + void likesProductAndIncrementsCount() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("같은 상품에 두 번 좋아요해도 likeCount는 1이다 (멱등성)") + @Test + void likeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when + likeFacade.like(1L, productId); + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 취소 후 다시 좋아요하면 복원된다") + @Test + void restoresAfterUnlike() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Unlike { + + @DisplayName("좋아요를 취소하면 likeCount가 감소한다") + @Test + void unlikeDecrementsCount() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + + // when + likeFacade.unlike(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + + @DisplayName("좋아요하지 않은 상품을 취소해도 예외가 발생하지 않는다 (멱등성)") + @Test + void unlikeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + + // when & then — 예외 없이 정상 완료 + likeFacade.unlike(1L, productId); + } + + @DisplayName("이미 취소한 좋아요를 다시 취소해도 예외가 발생하지 않는다") + @Test + void doubleUnlikeIsIdempotent() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when & then — 예외 없이 정상 완료 + likeFacade.unlike(1L, productId); + + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + } + + @DisplayName("좋아요 목록 조회") + @Nested + class GetMyLikes { + + @DisplayName("본인의 좋아요 목록을 조회할 수 있다") + @Test + void getsMyLikes() { + // given + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(2); + } + + @DisplayName("삭제된 상품의 좋아요는 목록에서 제외된다") + @Test + void excludesDeletedProducts() { + // given + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + productService.delete(p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).productId()).isEqualTo(p1) + ); + } + + @DisplayName("다른 사용자의 좋아요는 조회되지 않는다") + @Test + void doesNotReturnOtherUserLikes() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.like(2L, productId); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..697a0f91f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,104 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +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 static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class LikeFacadeTest { + + @InjectMocks + private LikeFacade likeFacade; + + @Mock + private LikeService likeService; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @DisplayName("좋아요 등록") + @Nested + class Register { + + @DisplayName("새로 생성되면 상품 좋아요 수를 증가시킨다") + @Test + void increasesLikeCountOnNewLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.register(memberId, productId)).willReturn(true); + // act + likeFacade.register(memberId, productId); + // assert + then(productService).should().increaseLikeCount(productId); + } + + @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") + @Test + void doesNotIncreaseLikeCountOnExistingLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.register(memberId, productId)).willReturn(false); + // act + likeFacade.register(memberId, productId); + // assert + then(productService).should(never()).increaseLikeCount(productId); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Cancel { + + @DisplayName("취소되면 상품 좋아요 수를 감소시킨다") + @Test + void decreasesLikeCountOnCancel() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.cancel(memberId, productId)).willReturn(true); + // act + likeFacade.cancel(memberId, productId); + // assert + then(productService).should().decreaseLikeCount(productId); + } + + @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") + @Test + void doesNotDecreaseLikeCountWhenNotExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productService.getById(productId)).willReturn(product); + given(likeService.cancel(memberId, productId)).willReturn(false); + // act + likeFacade.cancel(memberId, productId); + // assert + then(productService).should(never()).decreaseLikeCount(productId); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java new file mode 100644 index 000000000..e128c75a5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceIntegrationTest.java @@ -0,0 +1,81 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberAuthServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 인증") + @Nested + class Authenticate { + + @DisplayName("올바른 loginId와 비밀번호면 회원을 반환한다") + @Test + void returnsMemberOnValidCredentials() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + MemberModel result = memberAuthService.authenticate("kwonmo", "Test1234!"); + + // then + assertThat(result.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("비밀번호가 틀리면 인증 실패 예외가 발생한다") + @Test + void throwsOnWrongPassword() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + + @DisplayName("존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java new file mode 100644 index 000000000..131af4cfd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthServiceTest.java @@ -0,0 +1,100 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 MemberAuthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberAuthService memberAuthService; + + @BeforeEach + void setUp() { + memberAuthService = new MemberAuthService(memberRepository, passwordEncoder); + } + + @DisplayName("회원 인증") + @Nested + class Authenticate { + + @DisplayName("올바른 자격 증명이면 회원을 반환한다") + @Test + void returnsMemberOnValidCredentials() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + MemberModel member = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches(rawPassword, "encoded")).thenReturn(true); + + // when + MemberModel result = memberAuthService.authenticate(loginId, rawPassword); + + // then + assertThat(result.loginId().value()).isEqualTo(loginId); + } + + @DisplayName("존재하지 않는 loginId면 MEMBER_NOT_FOUND 예외를 던진다") + @Test + void throwsOnNonExistentLoginId() { + // given + when(memberRepository.findByLoginId("nobody")).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + verify(passwordEncoder, never()).matches(any(), any()); + } + + @DisplayName("비밀번호가 틀리면 인증 실패 예외를 던진다") + @Test + void throwsOnWrongPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId("kwonmo")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("WrongPass1!", "encoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 000000000..4d456bdfa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,108 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeTest { + + @Mock + private MemberSignupService memberSignupService; + + @Mock + private MemberAuthService memberAuthService; + + @Mock + private MemberPasswordService memberPasswordService; + + private MemberFacade memberFacade; + + @BeforeEach + void setUp() { + memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("SignupService에 위임하고 MemberInfo를 반환한다") + @Test + void delegatesToSignupService() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")).thenReturn(member); + + // when + MemberInfo result = memberFacade.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("kwonmo"), + () -> assertThat(result.name()).isEqualTo("양권모"), + () -> assertThat(result.email()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("내 정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("인증 후 마스킹된 이름으로 반환한다") + @Test + void returnsWithMaskedName() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + // when + MemberInfo result = memberFacade.getMyInfo(member); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("kwonmo"), + () -> assertThat(result.name()).isEqualTo("양권*"), + () -> assertThat(result.email()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("인증 후 PasswordService에 위임한다") + @Test + void delegatesToPasswordService() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + // when + memberFacade.changePassword(member, "Current1!", "NewPass5678!"); + + // then + verify(memberPasswordService).changePassword(member, "Current1!", "NewPass5678!"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java new file mode 100644 index 000000000..17852eee1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceIntegrationTest.java @@ -0,0 +1,119 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberPasswordServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private MemberPasswordService memberPasswordService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호면 변경에 성공한다") + @Test + void changesPasswordSuccessfully() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + memberPasswordService.changePassword(member, "Test1234!", "NewPass5678!"); + + // then + MemberModel updated = memberAuthService.authenticate("kwonmo", "NewPass5678!"); + assertThat(updated.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("현재 비밀번호가 틀리면 PASSWORD_MISMATCH 예외가 발생한다") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + + @DisplayName("새 비밀번호가 현재와 같으면 PASSWORD_SAME_AS_OLD 예외가 발생한다") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + } + + @DisplayName("새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외가 발생한다") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면 거부한다") + @Test + void rejectsNewPasswordContainingBirthDate() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "양권모", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Pass19980916!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java new file mode 100644 index 000000000..b6e2b187d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberPasswordServiceTest.java @@ -0,0 +1,125 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 MemberPasswordServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberPasswordService memberPasswordService; + + @BeforeEach + void setUp() { + memberPasswordService = new MemberPasswordService(memberRepository, passwordEncoder); + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면 새 비밀번호로 암호화해서 저장한다") + @Test + void encodesAndSavesNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + when(passwordEncoder.matches("NewPass5678!", "currentEncoded")).thenReturn(false); + when(passwordEncoder.encode("NewPass5678!")).thenReturn("newEncoded"); + when(passwordEncoder.matches("NewPass5678!", "newEncoded")).thenReturn(true); + + // when + memberPasswordService.changePassword(member, "Current1234!", "NewPass5678!"); + + // then + assertThat(member.matchesPassword("NewPass5678!", passwordEncoder)).isTrue(); + verify(memberRepository).save(member); + verify(passwordEncoder).encode("NewPass5678!"); + } + + @DisplayName("현재 비밀번호가 틀리면 저장하지 않고 예외를 던진다") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("WrongPass1!", "currentEncoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + verify(memberRepository, never()).save(any()); + verify(passwordEncoder, never()).encode(any()); + } + + @DisplayName("새 비밀번호가 현재와 같으면 저장하지 않는다") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Test1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("새 비밀번호가 규칙에 맞지 않으면 INVALID_PASSWORD 예외를 던진다") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Current1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).save(any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java new file mode 100644 index 000000000..1b6a203ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceIntegrationTest.java @@ -0,0 +1,119 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberSignupServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 회원이 생성되고 비밀번호가 암호화된다") + @Test + void createsMemberWithEncodedPassword() { + // given + String loginId = "kwonmo"; + String password = "Test1234!"; + String name = "양권모"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + String email = "kwonmo@example.com"; + + // when + MemberModel result = memberSignupService.signup(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.loginId().value()).isEqualTo(loginId), + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.birthDate()).isEqualTo(birthDate), + () -> assertThat(result.email().value()).isEqualTo(email), + () -> assertThat(result.matchesPassword(password, passwordEncoder)).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입하면 DUPLICATE_LOGIN_ID 예외가 발생한다") + @Test + void throwsOnDuplicateLoginId() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Other1234!", "박지훈", + LocalDate.of(1995, 5, 20), "jihun@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + } + + @DisplayName("loginId 형식이 잘못되면 INVALID_LOGIN_ID 예외가 발생한다") + @Test + void throwsOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("비밀번호 규칙을 위반하면 INVALID_PASSWORD 예외가 발생한다") + @Test + void throwsOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("생년월일이 포함된 비밀번호는 거부한다") + @Test + void rejectsPasswordContainingBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Pass19980916!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java new file mode 100644 index 000000000..97b113131 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberSignupServiceTest.java @@ -0,0 +1,116 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 MemberSignupServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberSignupService memberSignupService; + + @BeforeEach + void setUp() { + memberSignupService = new MemberSignupService(memberRepository, passwordEncoder); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 비밀번호를 암호화하고 저장한다") + @Test + void encodesPasswordAndSaves() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + String encodedPassword = "encoded_password"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when + MemberModel result = memberSignupService.signup(loginId, rawPassword, "양권모", birthDate, "kwonmo@example.com"); + + // then + assertThat(result.matchesPassword(rawPassword, passwordEncoder)).isTrue(); + verify(memberRepository).save(any(MemberModel.class)); + verify(passwordEncoder).encode(rawPassword); + } + + @DisplayName("이미 사용 중인 loginId면 저장하지 않고 예외를 던진다") + @Test + void throwsOnDuplicateLoginId() { + // given + String loginId = "kwonmo"; + MemberModel existing = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("기존회원"), + LocalDate.of(1998, 9, 16), new Email("exist@example.com")); + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup(loginId, "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("loginId 형식이 잘못되면 repository를 조회하지 않는다") + @Test + void skipsRepositoryOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user!", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + verify(memberRepository, never()).findByLoginId(any()); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("비밀번호 규칙 위반이면 repository를 조회하지 않는다") + @Test + void skipsRepositoryOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).findByLoginId(any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..977759387 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -0,0 +1,176 @@ +package com.loopers.application.order; + +import com.loopers.application.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Money; +import com.loopers.application.product.ProductService; +import com.loopers.application.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class OrderFacadeIntegrationTest { + + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductFacade productFacade; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private StockService stockService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } + private Long createProduct(String name, int price, Long brandId, int stock) { + return productFacade.register(name, "설명", new Money(price), brandId, stock).getId(); + } + + @DisplayName("주문 생성") + @Nested + class PlaceOrder { + + @DisplayName("단일 상품 주문이 성공한다") + @Test + void placesSingleItemOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2)), null); + assertAll( + () -> assertThat(result.order().userId()).isEqualTo(1L), + () -> assertThat(result.order().status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(258000)), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(result.items().get(0).quantity()).isEqualTo(2) + ); + } + + @DisplayName("복수 상품 주문이 성공한다") + @Test + void placesMultiItemOrder() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 5); + OrderResult result = orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 2), new OrderItemCommand(p2, 1)), null); + assertAll( + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(417000)), + () -> assertThat(result.items()).hasSize(2) + ); + } + + @DisplayName("주문 시 재고가 차감된다") + @Test + void deductsStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3)), null); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + } + + @DisplayName("재고 부족 시 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenInsufficientStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 2); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 5)), null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("두 번째 상품 재고 부족 시 첫 번째 상품 재고도 롤백된다") + @Test + void rollsBackOnPartialFailure() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 1); + assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)), null)); + assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + } + + @DisplayName("삭제된 상품 주문 시 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenProductDeleted() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + productService.delete(productId); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("스냅샷으로 주문 당시 상품 정보가 저장된다") + @Test + void savesProductSnapshot() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + OrderItemModel item = result.items().get(0); + assertAll( + () -> assertThat(item.productName()).isEqualTo("에어맥스"), + () -> assertThat(item.productPrice()).isEqualTo(new Money(129000)) + ); + } + } + + @DisplayName("주문 조회") + @Nested + class GetOrder { + + @DisplayName("본인의 주문을 조회할 수 있다") + @Test + void getsOwnOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + OrderModel order = orderService.getOrder(r.order().getId(), 1L); + assertThat(order.getId()).isEqualTo(r.order().getId()); + } + + @DisplayName("다른 사용자의 주문을 조회하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenAccessingOtherUserOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + CoreException result = assertThrows(CoreException.class, + () -> orderService.getOrder(r.order().getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 상품 목록을 조회할 수 있다") + @Test + void getsOrderItems() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2)), null); + List items = orderService.getOrderItems(r.order().getId()); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(items.get(0).quantity()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..8dd7a660b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,123 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +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.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @InjectMocks + private OrderFacade orderFacade; + + @Mock + private OrderService orderService; + + @Mock + private ProductService productService; + + @Mock + private StockService stockService; + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("정상적으로 주문을 생성한다") + @Test + void createsOrderSuccessfully() { + // arrange + Long memberId = 1L; + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), 1L); + ReflectionTestUtils.setField(product2, "id", 2L); + StockModel stock1 = new StockModel(1L, 100); + StockModel stock2 = new StockModel(2L, 50); + + given(productService.getById(1L)).willReturn(product1); + given(productService.getById(2L)).willReturn(product2); + given(stockService.getByProductId(1L)).willReturn(stock1); + given(stockService.getByProductId(2L)).willReturn(stock2); + given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + + List commands = List.of( + new OrderItemCommand(1L, 2), + new OrderItemCommand(2L, 1) + ); + // act + OrderInfo.Detail result = orderFacade.createOrder(memberId, commands); + // assert + assertAll( + () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(result.orderItems()).hasSize(2) + ); + } + + @DisplayName("삭제된 상품이 포함되면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnDeletedProduct() { + // arrange + Long memberId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product, "id", 1L); + product.delete(); + given(productService.getById(1L)).willReturn(product); + + List commands = List.of(new OrderItemCommand(1L, 1)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.createOrder(memberId, commands); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + then(orderService).should(never()).save(any()); + } + + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnInsufficientStock() { + // arrange + Long memberId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product, "id", 1L); + StockModel stock = new StockModel(1L, 5); + + given(productService.getById(1L)).willReturn(product); + given(stockService.getByProductId(1L)).willReturn(stock); + + List commands = List.of(new OrderItemCommand(1L, 10)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.createOrder(memberId, commands); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + then(orderService).should(never()).save(any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..28572b257 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,119 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.application.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.application.stock.StockService; +import com.loopers.domain.stock.StockStatus; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private StockService stockService; + + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + productFacade = new ProductFacade(productService, brandService, stockService); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("브랜드 검증 후 상품 저장 및 재고 생성을 orchestrate 한다") + @Test + void orchestratesRegistration() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + + when(brandService.getBrand(brandId)).thenReturn(brand); + when(productService.register("에어맥스", "러닝화", new Money(129000), brandId)).thenReturn(product); + + // when + ProductModel result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> verify(brandService).getBrand(brandId), + () -> verify(productService).register("에어맥스", "러닝화", new Money(129000), brandId), + () -> verify(stockService).create(any(), eq(100)) + ); + } + + @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = 1L; + when(brandService.getBrand(brandId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + // when & then + assertThrows(CoreException.class, + () -> productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100)); + } + } + + @DisplayName("상품 상세 조회 (Customer)") + @Nested + class GetProduct { + + @DisplayName("상품 + 브랜드명 + 재고상태를 조합하여 반환한다") + @Test + void returnsProductDetail() { + // given + Long productId = 1L; + Long brandId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + StockModel stock = new StockModel(productId, 100); + + when(productService.getProduct(productId)).thenReturn(product); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(productId)).thenReturn(stock); + + // when + ProductDetail result = productFacade.getProduct(productId); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.brandName()).isEqualTo("나이키"), + () -> assertThat(result.stockStatus()).isEqualTo(StockStatus.IN_STOCK) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..93b7242f3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -0,0 +1,250 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.application.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private ProductFacade productFacade; + + @Autowired + private BrandService brandService; + + @Autowired + private StockService stockService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name) { + return brandService.register(name, "설명").getId(); + } + + private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + return productFacade.register(name, description, price, brandId, initialStock); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면 상품과 재고가 생성된다") + @Test + void createsProductAndStock() { + // given + Long brandId = createBrand("나이키"); + + // when + ProductModel result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("에어맥스 90"), + () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + + StockModel stock = stockService.getByProductId(result.getId()); + assertThat(stock.quantity()).isEqualTo(100); + } + + @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = createBrand("나이키"); + brandService.delete(brandId); + + // when + CoreException result = assertThrows(CoreException.class, + () -> createProduct("에어맥스", "러닝화", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 조회") + @Nested + class GetProduct { + + @DisplayName("존재하고 미삭제 상태면 상품을 반환한다") + @Test + void returnsProduct() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.getProduct(saved.getId()); + + // then + assertThat(result.name()).isEqualTo("에어맥스 90"); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") + @Test + void throwsWhenDeleted() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("미삭제 상품만 페이징하여 반환한다") + @Test + void returnsNotDeletedProducts() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + ProductModel deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); + productService.delete(deleted.getId()); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("나이키"); + Long adidasId = createBrand("아디다스"); + createProduct("에어맥스 90", "러닝화", new Money(129000), nikeId, 100); + createProduct("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); + + // when + Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스 90"); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경할 수 있다") + @Test + void updatesSuccessfully() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스 95"), + () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.price()).isEqualTo(new Money(159000)) + ); + } + } + + @DisplayName("상품 삭제") + @Nested + class Delete { + + @DisplayName("soft delete 후 customer 조회에서 제외된다") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete 후 admin 조회에서는 포함된다") + @Test + void includedInAdminQueryAfterDelete() { + // given + Long brandId = createBrand("나이키"); + ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + ProductModel result = productService.getProductForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("브랜드별 상품 전체 삭제") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") + @Test + void softDeletesAllProducts() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); + + // when + productService.deleteAllByBrandId(brandId); + + // then + Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } +} 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..4187b82f9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -0,0 +1,259 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import 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.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(productRepository); + } + + @DisplayName("상품 등록") + @Nested + class Register { + + @DisplayName("정상적으로 등록된다") + @Test + void returnsSavedProduct() { + // given + String name = "에어맥스 90"; + String description = "러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + + when(productRepository.save(any(ProductModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.register(name, description, price, brandId); + + // then + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getPrice().value()).isEqualTo(129000) + ); + verify(productRepository).save(any(ProductModel.class)); + } + } + + @DisplayName("상품 조회") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + ProductModel result = productService.getById(id); + // assert + assertThat(result.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(productRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnDeletedProduct() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("상품 정보를 수정한다") + @Test + void updatesSuccessfully() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + ProductModel result = productService.update(id, "에어포스", "캐주얼", new Money(109000)); + // assert + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어포스"), + () -> assertThat(result.getPrice().value()).isEqualTo(109000) + ); + } + } + + @DisplayName("상품 삭제") + @Nested + class Delete { + + @DisplayName("상품을 soft delete 한다") + @Test + void softDeletesProduct() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.delete(id); + // assert + assertThat(product.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("상품 배치 조회") + @Nested + class GetProductsByIds { + + @DisplayName("ID 목록으로 미삭제 상품을 Map으로 반환한다") + @Test + void returnsMapOfProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + ProductModel product2 = new ProductModel("조던", "농구화", new Money(159000), 1L); + ReflectionTestUtils.setField(product2, "id", 2L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1, product2)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).name()).isEqualTo("에어맥스"), + () -> assertThat(result.get(2L).name()).isEqualTo("조던") + ); + } + + @DisplayName("삭제된 상품은 제외된다") + @Test + void excludesDeletedProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result).containsKey(1L), + () -> assertThat(result).doesNotContainKey(2L) + ); + } + } + + @DisplayName("브랜드별 상품 전체 삭제") + @Nested + class SoftDeleteByBrandId { + + @DisplayName("브랜드에 속한 모든 상품을 soft delete 한다") + @Test + void softDeletesAllByBrandId() { + // arrange + Long brandId = 1L; + ProductModel product1 = new ProductModel("에어맥스", "러닝", new Money(129000), brandId); + ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), brandId); + given(productRepository.findAllByBrandId(brandId)).willReturn(List.of(product1, product2)); + // act + productService.softDeleteByBrandId(brandId); + // assert + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + } + + @DisplayName("좋아요 수 관리") + @Nested + class LikeCount { + + @DisplayName("좋아요 수를 증가시킨다") + @Test + void increasesLikeCount() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.increaseLikeCount(id); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수를 감소시킨다") + @Test + void decreasesLikeCount() { + // arrange + Long id = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.increaseLikeCount(); + given(productRepository.findById(id)).willReturn(Optional.of(product)); + // act + productService.decreaseLikeCount(id); + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java new file mode 100644 index 000000000..a524ebc82 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java @@ -0,0 +1,113 @@ +package com.loopers.application.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +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.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class StockServiceTest { + + @InjectMocks + private StockService stockService; + + @Mock + private StockRepository stockRepository; + + @DisplayName("재고 조회") + @Nested + class GetByProductId { + + @DisplayName("상품ID로 재고를 조회한다") + @Test + void returnsStockForProductId() { + // arrange + Long productId = 1L; + StockModel stock = new StockModel(productId, 100); + given(stockRepository.findByProductId(productId)).willReturn(Optional.of(stock)); + // act + StockModel result = stockService.getByProductId(productId); + // assert + assertAll( + () -> assertThat(result.getProductId()).isEqualTo(productId), + () -> assertThat(result.getQuantity()).isEqualTo(100) + ); + } + + @DisplayName("재고가 없으면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentStock() { + // arrange + Long productId = 999L; + given(stockRepository.findByProductId(productId)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stockService.getByProductId(productId); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("재고 저장") + @Nested + class Save { + + @DisplayName("재고를 저장한다") + @Test + void savesStock() { + // arrange + StockModel stock = new StockModel(1L, 100); + given(stockRepository.save(any(StockModel.class))).willReturn(stock); + // act + StockModel result = stockService.save(1L, 100); + // assert + assertThat(result.getQuantity()).isEqualTo(100); + then(stockRepository).should().save(any(StockModel.class)); + } + } + + @DisplayName("상품별 재고 배치 조회") + @Nested + class GetByProductIds { + + @DisplayName("productId 목록으로 재고 Map을 반환한다") + @Test + void returnsMapOfStocks() { + // given + List productIds = List.of(1L, 2L); + StockModel stock1 = new StockModel(1L, 100); + StockModel stock2 = new StockModel(2L, 50); + when(stockRepository.findAllByProductIdIn(productIds)) + .thenReturn(List.of(stock1, stock2)); + + // when + Map result = stockService.getByProductIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).quantity()).isEqualTo(100), + () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..c2cc82d52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,113 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandModelTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("이름과 설명이 주어지면 정상 생성된다") + @Test + void createsWithNameAndDescription() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + // act + BrandModel brand = new BrandModel(name, description); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name), + () -> assertThat(brand.getDescription()).isEqualTo(description) + ); + } + + @DisplayName("설명 없이 이름만으로 생성할 수 있다") + @Test + void createsWithNameOnly() { + // arrange & act + BrandModel brand = new BrandModel("나이키", null); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("나이키"), + () -> assertThat(brand.getDescription()).isNull() + ); + } + + @DisplayName("이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new BrandModel(null, "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new BrandModel(" ", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 수정할 수 있다") + @Test + void updatesNameAndDescription() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + brand.update("아디다스", "독일 스포츠 브랜드"); + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("아디다스"), + () -> assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드") + ); + } + + @DisplayName("수정 시 이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullNameUpdate() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(null, "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수정 시 이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankNameUpdate() { + // arrange + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(" ", "설명"); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java new file mode 100644 index 000000000..33c9fcf4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandNameTest { + + @DisplayName("BrandName 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new BrandName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 값으로 생성할 수 있다") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "나이키"; + + // when + BrandName name = new BrandName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + BrandName one = new BrandName("나이키"); + BrandName another = new BrandName("나이키"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + BrandName one = new BrandName("나이키"); + BrandName another = new BrandName("아디다스"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java new file mode 100644 index 000000000..d5a58a496 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponIssueModelTest { + + private CouponIssueModel createIssue(LocalDateTime expiredAt) { + return new CouponIssueModel(1L, 100L, expiredAt); + } + + @DisplayName("쿠폰 발급 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + + // then + assertAll( + () -> assertThat(issue.couponId()).isEqualTo(1L), + () -> assertThat(issue.userId()).isEqualTo(100L), + () -> assertThat(issue.status()).isEqualTo(CouponIssueStatus.AVAILABLE), + () -> assertThat(issue.isUsed()).isFalse() + ); + } + + @DisplayName("couponId가 null이면 예외가 발생한다") + @Test + void throwsWhenCouponIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponIssueModel(null, 100L, LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰 사용") + @Nested + class Use { + + @DisplayName("사용 가능한 쿠폰을 사용할 수 있다") + @Test + void usesAvailableCoupon() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + issue.use(999L); + + // then + assertAll( + () -> assertThat(issue.isUsed()).isTrue(), + () -> assertThat(issue.status()).isEqualTo(CouponIssueStatus.USED), + () -> assertThat(issue.orderId()).isEqualTo(999L) + ); + } + + @DisplayName("이미 사용된 쿠폰은 다시 사용할 수 없다") + @Test + void throwsWhenAlreadyUsed() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.use(999L); + + // when & then + CoreException result = assertThrows(CoreException.class, () -> issue.use(1000L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료된 쿠폰은 사용할 수 없다") + @Test + void throwsWhenExpired() { + // given + CouponIssueModel issue = createIssue(LocalDateTime.of(2020, 1, 1, 0, 0)); + + // when & then + CoreException result = assertThrows(CoreException.class, () -> issue.use(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소유자 검증") + @Nested + class ValidateOwner { + + @DisplayName("본인의 쿠폰이면 통과한다") + @Test + void passesForOwner() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.validateOwner(100L); // 예외 없이 통과 + } + + @DisplayName("다른 사용자의 쿠폰이면 예외가 발생한다") + @Test + void throwsForNonOwner() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + CoreException result = assertThrows(CoreException.class, + () -> issue.validateOwner(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 판별") + @Nested + class Status { + + @DisplayName("사용 전 + 만료 전이면 AVAILABLE이다") + @Test + void availableWhenNotUsedNotExpired() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.AVAILABLE); + } + + @DisplayName("사용 후에는 USED이다") + @Test + void usedAfterUse() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2099, 12, 31, 23, 59)); + issue.use(999L); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.USED); + } + + @DisplayName("만료 후에는 EXPIRED이다") + @Test + void expiredWhenPastDate() { + CouponIssueModel issue = createIssue(LocalDateTime.of(2020, 1, 1, 0, 0)); + assertThat(issue.status()).isEqualTo(CouponIssueStatus.EXPIRED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java new file mode 100644 index 000000000..763656d9b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponModelTest { + + @DisplayName("쿠폰 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + CouponModel coupon = new CouponModel("10% 할인", CouponType.RATE, 10, + new Money(10000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // then + assertAll( + () -> assertThat(coupon.name()).isEqualTo("10% 할인"), + () -> assertThat(coupon.type()).isEqualTo(CouponType.RATE), + () -> assertThat(coupon.value()).isEqualTo(10), + () -> assertThat(coupon.minOrderAmount()).isEqualTo(new Money(10000)) + ); + } + + @DisplayName("이름이 빈 값이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenNameBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponModel("", CouponType.FIXED, 1000, null, + LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("값이 0이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenValueZero() { + CoreException result = assertThrows(CoreException.class, + () -> new CouponModel("쿠폰", CouponType.FIXED, 0, null, + LocalDateTime.of(2099, 12, 31, 23, 59))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("할인 계산") + @Nested + class CalculateDiscount { + + @DisplayName("정액 쿠폰은 고정 금액을 할인한다") + @Test + void fixedDiscount() { + // given + CouponModel coupon = new CouponModel("5000원 할인", CouponType.FIXED, 5000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(20000)); + + // then + assertThat(discount).isEqualTo(new Money(5000)); + } + + @DisplayName("정액 쿠폰 할인이 주문 금액보다 크면 주문 금액만큼만 할인한다") + @Test + void fixedDiscountCappedAtOrderAmount() { + // given + CouponModel coupon = new CouponModel("50000원 할인", CouponType.FIXED, 50000, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(20000)); + + // then + assertThat(discount).isEqualTo(new Money(20000)); + } + + @DisplayName("정률 쿠폰은 주문 금액의 비율로 할인한다") + @Test + void rateDiscount() { + // given + CouponModel coupon = new CouponModel("10% 할인", CouponType.RATE, 10, + null, LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when + Money discount = coupon.calculateDiscount(new Money(100000)); + + // then + assertThat(discount).isEqualTo(new Money(10000)); + } + } + + @DisplayName("사용 가능 검증") + @Nested + class ValidateUsable { + + @DisplayName("만료된 쿠폰은 사용할 수 없다") + @Test + void throwsWhenExpired() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + null, LocalDateTime.of(2020, 1, 1, 0, 0)); + + // when & then + CoreException result = assertThrows(CoreException.class, + () -> coupon.validateUsable(new Money(10000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최소 주문 금액 미달 시 사용할 수 없다") + @Test + void throwsWhenBelowMinOrderAmount() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + new Money(50000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when & then + CoreException result = assertThrows(CoreException.class, + () -> coupon.validateUsable(new Money(10000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 쿠폰은 검증을 통과한다") + @Test + void passesWhenValid() { + // given + CouponModel coupon = new CouponModel("쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.of(2099, 12, 31, 23, 59)); + + // when & then (예외 없이 통과) + coupon.validateUsable(new Money(20000)); + } + } +} 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 index 44ca7576e..5a94cb896 100644 --- 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 @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") + @DisplayName("예시 모델 생성") @Nested class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") + @DisplayName("제목과 설명이 모두 주어지면 정상 생성된다") @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { + void createsWithNameAndDescription() { // arrange String name = "제목"; String description = "설명"; @@ -32,9 +32,9 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { ); } - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("제목이 공백이면 BAD_REQUEST 예외가 발생한다") @Test - void throwsBadRequestException_whenTitleIsBlank() { + void throwsOnBlankTitle() { // arrange String name = " "; @@ -47,9 +47,9 @@ void throwsBadRequestException_whenTitleIsBlank() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("설명이 비어있으면 BAD_REQUEST 예외가 발생한다") @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { + void throwsOnEmptyDescription() { // arrange String description = ""; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..13b248539 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeModelTest { + + @DisplayName("좋아요 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Long productId = 2L; + + // when + LikeModel like = new LikeModel(userId, productId); + + // then + assertAll( + () -> assertThat(like.userId()).isEqualTo(userId), + () -> assertThat(like.productId()).isEqualTo(productId) + ); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(null, 1L)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(1L, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..4d25df4e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @DisplayName("좋아요 등록") + @Nested + class Register { + + @DisplayName("좋아요가 없으면 새로 생성하고 true를 반환한다") + @Test + void returnsTrueWhenNewLike() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + given(likeRepository.save(any(LikeModel.class))).willReturn(new LikeModel(memberId, productId)); + // act + boolean result = likeService.register(memberId, productId); + // assert + assertThat(result).isTrue(); + then(likeRepository).should().save(any(LikeModel.class)); + } + + @DisplayName("이미 좋아요가 존재하면 false를 반환한다") + @Test + void returnsFalseWhenAlreadyExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)) + .willReturn(Optional.of(new LikeModel(memberId, productId))); + // act + boolean result = likeService.register(memberId, productId); + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Cancel { + + @DisplayName("좋아요가 존재하면 삭제하고 true를 반환한다") + @Test + void returnsTrueWhenCancelled() { + // arrange + Long memberId = 1L; + Long productId = 100L; + LikeModel like = new LikeModel(memberId, productId); + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.of(like)); + // act + boolean result = likeService.cancel(memberId, productId); + // assert + assertThat(result).isTrue(); + then(likeRepository).should().delete(like); + } + + @DisplayName("좋아요가 없으면 false를 반환한다") + @Test + void returnsFalseWhenNotExists() { + // arrange + Long memberId = 1L; + Long productId = 100L; + given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + // act + boolean result = likeService.cancel(memberId, productId); + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("내 좋아요 목록 조회") + @Nested + class GetMyLikes { + + @DisplayName("회원의 좋아요 목록을 페이징으로 반환한다") + @Test + void returnsPagedLikes() { + // arrange + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List likes = List.of(new LikeModel(memberId, 1L), new LikeModel(memberId, 2L)); + given(likeRepository.findAllByMemberId(memberId, pageable)).willReturn(new PageImpl<>(likes)); + // act + Page result = likeService.getMyLikes(memberId, pageable); + // assert + assertThat(result.getContent()).hasSize(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java new file mode 100644 index 000000000..b266b4ae4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeToggleServiceTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.like; + +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class LikeToggleServiceTest { + + private LikeToggleService likeToggleService; + + @BeforeEach + void setUp() { + likeToggleService = new LikeToggleService(); + } + + @DisplayName("좋아요 토글 - like") + @Nested + class Like { + + @DisplayName("좋아요가 없으면 새로 생성하고 countChanged=true를 반환한다") + @Test + void createsNewLikeWhenNoneExists() { + // given + Optional existing = Optional.empty(); + + // when + LikeResult result = likeToggleService.like(existing, 1L, 100L); + + // then + assertAll( + () -> assertThat(result.newLike()).isPresent(), + () -> assertThat(result.newLike().get().userId()).isEqualTo(1L), + () -> assertThat(result.newLike().get().productId()).isEqualTo(100L), + () -> assertThat(result.countChanged()).isTrue() + ); + } + + @DisplayName("삭제된 좋아요가 있으면 복구하고 countChanged=true를 반환한다") + @Test + void restoresDeletedLike() { + // given + LikeModel deletedLike = new LikeModel(1L, 100L); + deletedLike.delete(); + Optional existing = Optional.of(deletedLike); + + // when + LikeResult result = likeToggleService.like(existing, 1L, 100L); + + // then + assertAll( + () -> assertThat(result.newLike()).isEmpty(), + () -> assertThat(result.countChanged()).isTrue(), + () -> assertThat(deletedLike.getDeletedAt()).isNull() + ); + } + + @DisplayName("이미 활성 좋아요가 있으면 아무것도 하지 않는다 (멱등)") + @Test + void skipsWhenAlreadyActive() { + // given + LikeModel activeLike = new LikeModel(1L, 100L); + Optional existing = Optional.of(activeLike); + + // when + LikeResult result = likeToggleService.like(existing, 1L, 100L); + + // then + assertAll( + () -> assertThat(result.newLike()).isEmpty(), + () -> assertThat(result.countChanged()).isFalse() + ); + } + } + + @DisplayName("좋아요 토글 - unlike") + @Nested + class Unlike { + + @DisplayName("활성 좋아요를 삭제한다") + @Test + void deletesLike() { + // given + LikeModel activeLike = new LikeModel(1L, 100L); + + // when + likeToggleService.unlike(activeLike); + + // then + assertThat(activeLike.getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java new file mode 100644 index 000000000..6030b304a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailTest { + + @DisplayName("Email 생성") + @Nested + class Create { + + @DisplayName("유효한 이메일로 생성할 수 있다") + @Test + void validEmailCreatesSuccessfully() { + // given + String value = "kwonmo@example.com"; + + // when + Email email = new Email(value); + + // then + assertThat(email.value()).isEqualTo(value); + } + + @DisplayName("이메일 형식이 올바르지 않으면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"", "testexample.com", "test@", "@example.com"}) + void rejectsInvalidEmailFormats(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Email(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_EMAIL); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("kwonmo@example.com"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("jihun@example.com"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java new file mode 100644 index 000000000..98ca41492 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import 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.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginIdTest { + + @DisplayName("LoginId 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("특수문자나 한글이 포함되면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test유저", "hello world!", "user#1"}) + void rejectsInvalidCharacters(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("영문, 숫자 조합으로 생성할 수 있다") + @ParameterizedTest + @ValueSource(strings = {"testuser", "12345", "user123"}) + void acceptsAlphanumericValues(String value) { + // given & when + LoginId loginId = new LoginId(value); + + // then + assertThat(loginId.value()).isEqualTo(value); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("kwonmo"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("jihun"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 000000000..ff9a0e1be --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberModelTest { + + @Mock + private PasswordEncoder passwordEncoder; + + @DisplayName("회원 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("양권모"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + Email email = new Email("kwonmo@example.com"); + when(passwordEncoder.matches("rawPassword", "encodedPassword")).thenReturn(true); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, email); + + // then + assertAll( + () -> assertThat(member.loginId()).isEqualTo(loginId), + () -> assertThat(member.matchesPassword("rawPassword", passwordEncoder)).isTrue(), + () -> assertThat(member.name()).isEqualTo(name), + () -> assertThat(member.birthDate()).isEqualTo(birthDate), + () -> assertThat(member.email()).isEqualTo(email) + ); + } + + @DisplayName("email이 null이어도 생성할 수 있다") + @Test + void createsWithNullEmail() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("양권모"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, null); + + // then + assertThat(member.email()).isNull(); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("새 비밀번호로 변경하면 이전 비밀번호는 매칭되지 않는다") + @Test + void newPasswordReplacesOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "oldEncodedPassword", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com") + ); + String newEncodedPassword = "newEncodedPassword"; + when(passwordEncoder.matches("newRaw", "newEncodedPassword")).thenReturn(true); + when(passwordEncoder.matches("oldRaw", "newEncodedPassword")).thenReturn(false); + + // when + member.changePassword(newEncodedPassword); + + // then + assertThat(member.matchesPassword("newRaw", passwordEncoder)).isTrue(); + assertThat(member.matchesPassword("oldRaw", passwordEncoder)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java new file mode 100644 index 000000000..d72086b0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberNameTest { + + @DisplayName("MemberName 생성") + @Nested + class Create { + + @DisplayName("null, 빈 문자열, 공백은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new MemberName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_NAME); + } + + @DisplayName("유효한 이름으로 생성할 수 있다") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "양권모"; + + // when + MemberName name = new MemberName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("이름 마스킹") + @Nested + class Masked { + + @DisplayName("글자 수에 따라 마지막 글자를 마스킹한다") + @ParameterizedTest + @CsvSource({"양, *", "양권, 양*", "양권모, 양권*"}) + void masksLastCharacter(String input, String expected) { + // given + MemberName name = new MemberName(input); + + // when + String result = name.masked(); + + // then + assertThat(result).isEqualTo(expected); + } + } + + @DisplayName("동등성 비교") + @Nested + class Equals { + + @DisplayName("같은 값이면 동일하다") + @Test + void sameValueMeansEqual() { + // given + MemberName one = new MemberName("양권모"); + MemberName another = new MemberName("양권모"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("다른 값이면 다르다") + @Test + void differentValueMeansNotEqual() { + // given + MemberName one = new MemberName("양권모"); + MemberName another = new MemberName("박지훈"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..02c2fea85 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,90 @@ +package com.loopers.domain.member; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("loginId로 회원 조회") + @Nested + class FindByLoginId { + + @DisplayName("존재하지 않는 loginId면 빈 Optional을 반환한다") + @Test + void returnsEmptyForNonExistentLoginId() { + // given + String loginId = "nonexistent"; + + // when + Optional result = memberRepository.findByLoginId(loginId); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("존재하는 loginId면 저장된 회원을 반환한다") + @Test + void returnsMemberForExistingLoginId() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + memberRepository.save(member); + + // when + Optional result = memberRepository.findByLoginId("kwonmo"); + + // then + assertThat(result).isPresent(); + assertAll( + () -> assertThat(result.get().loginId().value()).isEqualTo("kwonmo"), + () -> assertThat(result.get().name().value()).isEqualTo("양권모"), + () -> assertThat(result.get().birthDate()).isEqualTo(LocalDate.of(1998, 9, 16)), + () -> assertThat(result.get().email().value()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("회원 저장") + @Nested + class Save { + + @DisplayName("유효한 회원 정보를 저장하면 ID가 생성된다") + @Test + void generatesIdOnSave() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + // when + MemberModel saved = memberRepository.save(member); + + // then + assertThat(saved.getId()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java new file mode 100644 index 000000000..8caedef1b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java @@ -0,0 +1,173 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordTest { + + @DisplayName("Password 생성") + @Nested + class Create { + + @DisplayName("null이나 빈 문자열은 허용하지 않는다") + @ParameterizedTest + @NullAndEmptySource + void rejectsNullOrEmpty(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("유효한 비밀번호로 생성할 수 있다") + @Test + void validPasswordCreatesSuccessfully() { + // given + String value = "ValidPass1!"; + + // when + Password password = new Password(value); + + // then + assertThat(password.value()).isEqualTo(value); + } + + @DisplayName("8~16자 범위 내의 비밀번호는 허용된다") + @ParameterizedTest + @ValueSource(strings = {"Abcd123!", "Abcd1234!@#$Efgh"}) + void acceptsPasswordsWithinLengthRange(String value) { + // given & when & then + assertDoesNotThrow(() -> new Password(value)); + } + + @DisplayName("8~16자 범위를 벗어나면 생성할 수 없다") + @ParameterizedTest + @ValueSource(strings = {"Abc123!", "Abcd1234!@#$Efghi"}) + void rejectsPasswordsOutsideLengthRange(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("한글이 포함되면 생성할 수 없다") + @Test + void rejectsKoreanCharacters() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password("Abcd123한글")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + } + + @DisplayName("생년월일 포함 여부 검증") + @Nested + class ValidateAgainst { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1998, 9, 16); + + @DisplayName("YYYYMMDD 형식의 생년월일이 포함되면 예외가 발생한다") + @Test + void rejectsPasswordContainingFullBirthDate() { + // given + Password password = new Password("Pass19980916!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("YYMMDD 형식의 생년월일이 포함되어도 예외가 발생한다") + @Test + void rejectsPasswordContainingShortBirthDate() { + // given + Password password = new Password("Pass980916!!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일이 포함되지 않으면 통과한다") + @Test + void passesWhenBirthDateNotIncluded() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(BIRTH_DATE)); + } + + @DisplayName("birthDate가 null이면 검증을 건너뛴다") + @Test + void skipsValidationWhenBirthDateIsNull() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(null)); + } + } + + @DisplayName("of 팩토리 메서드") + @Nested + class OfFactory { + + @DisplayName("유효한 비밀번호와 생년월일로 생성할 수 있다") + @Test + void createsWithValidPasswordAndBirthDate() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", LocalDate.of(1998, 9, 16))); + } + + @DisplayName("형식이 잘못되면 INVALID_PASSWORD 예외가 발생한다") + @Test + void rejectsInvalidFormat() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("short", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("생년월일이 포함된 비밀번호는 거부한다") + @Test + void rejectsPasswordWithBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("Pass19980916!", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일이 null이면 형식만 검증한다") + @Test + void onlyValidatesFormatWhenBirthDateIsNull() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", null)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..d98918bd1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderItemModelTest { + + @DisplayName("주문 상품 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long orderId = 1L; + Long productId = 2L; + String productName = "에어맥스 90"; + Money productPrice = new Money(129000); + int quantity = 2; + + // when + OrderItemModel item = new OrderItemModel(orderId, productId, productName, productPrice, quantity); + + // then + assertAll( + () -> assertThat(item.orderId()).isEqualTo(orderId), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo(productName), + () -> assertThat(item.productPrice()).isEqualTo(productPrice), + () -> assertThat(item.quantity()).isEqualTo(quantity) + ); + } + + @DisplayName("orderId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenOrderIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(null, 1L, "상품", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, null, "상품", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductNameNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, null, new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductNameBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, " ", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productPrice가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenProductPriceNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "상품", null, 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity가 0이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenQuantityZero() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "상품", new Money(1000), 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소계 계산") + @Nested + class Subtotal { + + @DisplayName("가격 * 수량으로 소계를 계산한다") + @Test + void calculatesSubtotal() { + OrderItemModel item = new OrderItemModel(1L, 1L, "에어맥스", new Money(129000), 2); + assertThat(item.subtotal()).isEqualTo(new Money(258000)); + } + + @DisplayName("수량이 1이면 가격과 소계가 같다") + @Test + void subtotalEqualsUnitPriceWhenQuantityIsOne() { + Money price = new Money(59000); + OrderItemModel item = new OrderItemModel(1L, 1L, "양말", price, 1); + assertThat(item.subtotal()).isEqualTo(price); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..92ff4e9d6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderModelTest { + + @DisplayName("주문 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Money totalAmount = new Money(258000); + + // when + OrderModel order = new OrderModel(userId, totalAmount, Money.ZERO, null); + + // then + assertAll( + () -> assertThat(order.userId()).isEqualTo(userId), + () -> assertThat(order.status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.totalAmount()).isEqualTo(totalAmount) + ); + } + + @DisplayName("생성 시 상태는 CREATED이다") + @Test + void defaultStatusIsCreated() { + // given & when + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + + // then + assertThat(order.status()).isEqualTo(OrderStatus.CREATED); + } + + @DisplayName("userId가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(null, new Money(10000), Money.ZERO, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsWhenTotalAmountNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(1L, null, Money.ZERO, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..1bda80473 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,82 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @InjectMocks + private OrderService orderService; + + @Mock + private OrderRepository orderRepository; + + @DisplayName("주문 저장") + @Nested + class Save { + + @DisplayName("주문을 저장한다") + @Test + void savesOrder() { + // arrange + OrderModel order = new OrderModel(1L); + given(orderRepository.save(any(OrderModel.class))).willReturn(order); + // act + OrderModel result = orderService.save(order); + // assert + assertThat(result.getMemberId()).isEqualTo(1L); + then(orderRepository).should().save(order); + } + } + + @DisplayName("주문 조회") + @Nested + class GetById { + + @DisplayName("존재하는 주문을 반환한다") + @Test + void returnsForExistingId() { + // arrange + Long id = 1L; + OrderModel order = new OrderModel(1L); + ReflectionTestUtils.setField(order, "id", id); + given(orderRepository.findById(id)).willReturn(Optional.of(order)); + // act + OrderModel result = orderService.getById(id); + // assert + assertThat(result.getMemberId()).isEqualTo(1L); + } + + @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") + @Test + void throwsOnNonExistentId() { + // arrange + Long id = 999L; + given(orderRepository.findById(id)).willReturn(Optional.empty()); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.getById(id); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..17a0a62a2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MoneyTest { + + @DisplayName("Money 생성") + @Nested + class Create { + + @DisplayName("0원으로 생성할 수 있다") + @Test + void createsWithZero() { + // arrange & act + Money money = new Money(0); + // assert + assertThat(money.value()).isEqualTo(0); + } + + @DisplayName("양수 금액으로 생성할 수 있다") + @Test + void createsWithPositiveValue() { + // arrange & act + Money money = new Money(10000); + // assert + assertThat(money.value()).isEqualTo(10000); + } + + @DisplayName("음수 금액이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeValue() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new Money(-1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Money 연산") + @Nested + class Operations { + + @DisplayName("multiply: 금액에 수량을 곱한 새 Money를 반환한다") + @Test + void multipliesValue() { + // arrange + Money money = new Money(1000); + // act + Money result = money.multiply(3); + // assert + assertAll( + () -> assertThat(result.value()).isEqualTo(3000), + () -> assertThat(result).isNotSameAs(money) + ); + } + + @DisplayName("add: 두 Money를 더한 새 Money를 반환한다") + @Test + void addsValues() { + // arrange + Money a = new Money(1000); + Money b = new Money(2000); + // act + Money result = a.add(b); + // assert + assertAll( + () -> assertThat(result.value()).isEqualTo(3000), + () -> assertThat(result).isNotSameAs(a) + ); + } + } + + @DisplayName("Money 동등성") + @Nested + class Equality { + + @DisplayName("같은 금액의 Money는 동등하다") + @Test + void equalForSameValue() { + // arrange + Money a = new Money(1000); + Money b = new Money(1000); + // assert + assertAll( + () -> assertThat(a).isEqualTo(b), + () -> assertThat(a.hashCode()).isEqualTo(b.hashCode()) + ); + } + + @DisplayName("다른 금액의 Money는 동등하지 않다") + @Test + void notEqualForDifferentValue() { + // arrange + Money a = new Money(1000); + Money b = new Money(2000); + // assert + assertThat(a).isNotEqualTo(b); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..72b528ef1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,158 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductModelTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("이름, 설명, 가격, 브랜드ID가 주어지면 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange + String name = "에어맥스"; + String description = "러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + // act + ProductModel product = new ProductModel(name, description, price, brandId); + // assert + assertAll( + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getDescription()).isEqualTo(description), + () -> assertThat(product.getPrice().value()).isEqualTo(129000), + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getLikeCount()).isEqualTo(0) + ); + } + + @DisplayName("이름이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel(null, "설명", new Money(10000), 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankName() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel(" ", "설명", new Money(10000), 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullPrice() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new ProductModel("상품", "설명", null, 1L); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("이름, 설명, 가격을 수정할 수 있다") + @Test + void updatesSuccessfully() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.update("에어포스", "캐주얼", new Money(109000)); + // assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("에어포스"), + () -> assertThat(product.getDescription()).isEqualTo("캐주얼"), + () -> assertThat(product.getPrice().value()).isEqualTo(109000) + ); + } + + @DisplayName("수정 시 이름이 공백이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnBlankNameUpdate() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + product.update(" ", "설명", new Money(10000)); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수정 시 가격이 null이면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNullPriceUpdate() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + product.update("에어포스", "설명", null); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("likeCount") + @Nested + class LikeCount { + + @DisplayName("좋아요 수를 증가시킨다") + @Test + void increasesLikeCount() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.increaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수를 감소시킨다") + @Test + void decreasesLikeCount() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.increaseLikeCount(); + product.increaseLikeCount(); + // act + product.decreaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0 미만으로 감소하지 않는다") + @Test + void doesNotDecreaseBelowZero() { + // arrange + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + // act + product.decreaseLikeCount(); + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java new file mode 100644 index 000000000..bb6a6f73f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.stock; + +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 StockModelTest { + + @DisplayName("재고 생성") + @Nested + class Create { + + @DisplayName("상품ID와 수량으로 정상 생성된다") + @Test + void createsSuccessfully() { + // arrange & act + StockModel stock = new StockModel(1L, 100); + // assert + assertAll( + () -> assertThat(stock.getProductId()).isEqualTo(1L), + () -> assertThat(stock.getQuantity()).isEqualTo(100) + ); + } + + @DisplayName("수량이 0이면 정상 생성된다") + @Test + void createsWithZeroQuantity() { + // arrange & act + StockModel stock = new StockModel(1L, 0); + // assert + assertThat(stock.getQuantity()).isEqualTo(0); + } + + @DisplayName("수량이 음수면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeQuantity() { + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new StockModel(1L, -1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 차감") + @Nested + class Decrease { + + @DisplayName("충분한 재고가 있으면 차감된다") + @Test + void decreasesSuccessfully() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + stock.decrease(30); + // assert + assertThat(stock.getQuantity()).isEqualTo(70); + } + + @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnInsufficientStock() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.decrease(11); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 정확히 0까지 차감할 수 있다") + @Test + void decreasesToZero() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act + stock.decrease(10); + // assert + assertThat(stock.getQuantity()).isEqualTo(0); + } + } + + @DisplayName("재고 수정") + @Nested + class Update { + + @DisplayName("수량을 수정할 수 있다") + @Test + void updatesQuantity() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + stock.update(50); + // assert + assertThat(stock.getQuantity()).isEqualTo(50); + } + + @DisplayName("음수로 수정하면 BAD_REQUEST 예외가 발생한다") + @Test + void throwsOnNegativeQuantityUpdate() { + // arrange + StockModel stock = new StockModel(1L, 100); + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.update(-1); + }); + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 충분 여부 확인") + @Nested + class HasEnough { + + @DisplayName("재고가 충분하면 true를 반환한다") + @Test + void returnsTrueWhenEnough() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act & assert + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("재고가 부족하면 false를 반환한다") + @Test + void returnsFalseWhenNotEnough() { + // arrange + StockModel stock = new StockModel(1L, 10); + // act & assert + assertThat(stock.hasEnough(11)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java new file mode 100644 index 000000000..ad93bb46e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandAdminV1ApiE2ETest.java @@ -0,0 +1,169 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandAdminV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api-admin/v1/brands"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-AdminLdap", "admin"); + return headers; + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Create { + + @DisplayName("유효한 요청이면 브랜드를 등록하고 200을 반환한다") + @Test + void createsSuccessfully() { + // arrange + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("중복 이름이면 409를 반환한다") + @Test + void returnsConflictOnDuplicateName() { + // arrange + brandJpaRepository.save(new BrandModel("나이키", "기존")); + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "신규"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("관리자 헤더가 없으면 401을 반환한다") + @Test + void returnsUnauthorizedWithoutAdminHeader() { + // arrange + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠"); + HttpEntity entity = new HttpEntity<>(request); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/brands/{id}") + @Nested + class GetById { + + @DisplayName("존재하는 ID면 브랜드 정보를 반환한다") + @Test + void returnsForExistingId() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(brand.getId()), HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 ID면 404를 반환한다") + @Test + void returnsNotFoundForInvalidId() { + // arrange + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(999L), HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{id}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면 200을 반환한다") + @Test + void deletesSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(brand.getId()), HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} 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 index 1bb3dba65..bee78db56 100644 --- 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 @@ -51,7 +51,7 @@ void tearDown() { @DisplayName("GET /api/v1/examples/{id}") @Nested class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") + @DisplayName("존재하는 ID면 해당 예시 정보를 반환한다") @Test void returnsExampleInfo_whenValidIdIsProvided() { // arrange @@ -74,7 +74,7 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") + @DisplayName("숫자가 아닌 ID면 400을 반환한다") @Test void throwsBadRequest_whenIdIsNotProvided() { // arrange @@ -92,7 +92,7 @@ void throwsBadRequest_whenIdIsNotProvided() { ); } - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") + @DisplayName("존재하지 않는 ID면 404를 반환한다") @Test void throwsException_whenInvalidIdIsProvided() { // arrange diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..12ed55563 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,253 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; + + private final TestRestTemplate testRestTemplate; + private final MemberJpaRepository memberJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberJpaRepository memberJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberJpaRepository = memberJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private MemberV1Dto.SignupRequest signupRequest() { + return new MemberV1Dto.SignupRequest( + "kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com" + ); + } + + private void signupMember() { + testRestTemplate.exchange( + ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(signupRequest()), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + @DisplayName("POST /api/v1/users") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면 200과 회원 정보를 반환한다") + @Test + void returns200WithMemberInfo() { + // given + MemberV1Dto.SignupRequest request = signupRequest(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(request), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("kwonmo"), + () -> assertThat(response.getBody().data().name()).isEqualTo("양권모"), + () -> assertThat(response.getBody().data().email()).isEqualTo("kwonmo@example.com") + ); + } + + @DisplayName("중복 loginId로 가입하면 409를 반환한다") + @Test + void returns409OnDuplicateLoginId() { + // given + signupMember(); + MemberV1Dto.SignupRequest duplicateRequest = new MemberV1Dto.SignupRequest( + "kwonmo", "Other1234!", "박지훈", + LocalDate.of(1995, 5, 20), "jihun@example.com" + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(duplicateRequest), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("잘못된 loginId 형식이면 400을 반환한다") + @Test + void returns400OnInvalidLoginId() { + // given + MemberV1Dto.SignupRequest request = new MemberV1Dto.SignupRequest( + "test@user", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com" + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, + new HttpEntity<>(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("인증 성공 시 마스킹된 이름으로 반환한다") + @Test + void returns200WithMaskedName() { + // given + signupMember(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, + new HttpEntity<>(null, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("kwonmo"), + () -> assertThat(response.getBody().data().name()).isEqualTo("양권*"), + () -> assertThat(response.getBody().data().email()).isEqualTo("kwonmo@example.com") + ); + } + + @DisplayName("비밀번호가 틀리면 401을 반환한다") + @Test + void returns401OnWrongPassword() { + // given + signupMember(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, + new HttpEntity<>(null, authHeaders("kwonmo", "WrongPass1!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PUT /api/v1/users/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면 200을 반환한다") + @Test + void returns200OnValidRequest() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("Test1234!", "NewPass5678!"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("현재 비밀번호가 틀리면 400을 반환한다") + @Test + void returns400OnWrongCurrentPassword() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass5678!"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호 규칙을 위반하면 400을 반환한다") + @Test + void returns400OnInvalidNewPassword() { + // given + signupMember(); + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, + new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java new file mode 100644 index 000000000..309b54a2e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductAdminV1ApiE2ETest.java @@ -0,0 +1,149 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductAdminV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api-admin/v1/products"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + private static final Function ENDPOINT_STOCK = id -> ENDPOINT_BASE + "/" + id + "/stock"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductAdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-AdminLdap", "admin"); + return headers; + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Create { + + @DisplayName("유효한 요청이면 상품을 등록하고 200을 반환한다") + @Test + void createsSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스", "러닝화", 129000, brand.getId(), 100 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BASE, HttpMethod.POST, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + } + + @DisplayName("PATCH /api-admin/v1/products/{id}/stock") + @Nested + class UpdateStock { + + @DisplayName("재고를 수정하고 200을 반환한다") + @Test + void updatesStockSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + ProductAdminV1Dto.UpdateStockRequest request = new ProductAdminV1Dto.UpdateStockRequest(50); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_STOCK.apply(product.getId()), HttpMethod.PATCH, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(50) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{id}") + @Nested + class Delete { + + @DisplayName("상품을 삭제하면 200을 반환한다") + @Test + void deletesSuccessfully() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(product.getId()), HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..903319bb2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.interfaces.api.product.ProductV1Dto; +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 ProductV1ApiE2ETest { + + private static final String ENDPOINT_BASE = "/api/v1/products"; + private static final Function ENDPOINT_BY_ID = id -> ENDPOINT_BASE + "/" + id; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/products/{id}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면 상세 정보를 반환한다") + @Test + void returnsProductDetail() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠")); + ProductModel product = productJpaRepository.save( + new ProductModel("에어맥스", "러닝화", new Money(129000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 50)); + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(product.getId()), HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockStatus()).isEqualTo("IN_STOCK") + ); + } + + @DisplayName("존재하지 않는 상품이면 404를 반환한다") + @Test + void returnsNotFound() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_BY_ID.apply(999L), HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..9c4f78e12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,321 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + private Long createBrand(String name, String description) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class CustomerGetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드") + ); + } + + @DisplayName("미존재 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + deleteBrand(brandId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class AdminCreate { + + @DisplayName("유효한 정보로 등록하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("중복 브랜드명이면 409를 반환한다") + @Test + void returns409OnDuplicateName() { + // given + createBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("나이키", "다른 설명"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("빈 이름이면 400을 반환한다") + @Test + void returns400OnEmptyName() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("", "설명"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class AdminGetAll { + + @DisplayName("브랜드 목록을 페이징하여 반환한다") + @Test + void returns200WithPagedList() { + // given + createBrand("나이키", "스포츠"); + createBrand("아디다스", "스포츠"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class AdminGetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 200과 브랜드 정보를 반환한다") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("미존재 브랜드를 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class AdminUpdate { + + @DisplayName("수정 성공 시 200과 수정된 브랜드 정보를 반환한다") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("뉴발란스", "라이프스타일"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("뉴발란스"), + () -> assertThat(response.getBody().data().description()).isEqualTo("라이프스타일") + ); + } + + @DisplayName("중복명으로 변경하면 409를 반환한다") + @Test + void returns409OnDuplicateName() { + // given + createBrand("나이키", "스포츠"); + Long targetId = createBrand("아디다스", "스포츠"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("나이키", "변경 시도"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class AdminDelete { + + @DisplayName("삭제 성공 시 200을 반환한다") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("나이키", "스포츠 브랜드"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("미존재 브랜드 삭제 시 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..67152c396 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -0,0 +1,199 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +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.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private HttpHeaders adminHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-Ldap", "loopers.admin"); + return h; + } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "설명"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId) { + var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "테스트유저", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/users", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class LikeProduct { + + @DisplayName("상품에 좋아요를 등록한다") + @Test + void likesProduct() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("같은 상품에 두 번 좋아요해도 200을 반환한다 (멱등성)") + @Test + void likeIsIdempotent() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("인증 없이 좋아요하면 400을 반환한다") + @Test + void returns400WhenNoAuth() { + var response = testRestTemplate.exchange( + "/api/v1/products/1/likes", HttpMethod.POST, null, + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요를 취소한다") + @Test + void unlikesProduct() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("좋아요하지 않은 상품을 취소해도 200을 반환한다 (멱등성)") + @Test + void unlikeIsIdempotent() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/users/{userId}/likes") + @Nested + class GetMyLikes { + + @DisplayName("내가 좋아요한 상품 목록을 조회한다") + @Test + void getsMyLikes() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId); + Long p2 = createProduct("조던", 159000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + p1 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + testRestTemplate.exchange("/api/v1/products/" + p2 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/users/1/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("다른 유저의 좋아요 목록을 조회하면 400을 반환한다") + @Test + void returns400WhenUserIdMismatch() { + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/users/999/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..5b366f864 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -0,0 +1,204 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +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.*; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final MemberJpaRepository memberJpaRepository; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp, + MemberJpaRepository memberJpaRepository) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.memberJpaRepository = memberJpaRepository; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private HttpHeaders adminHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-Ldap", "loopers.admin"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "설명"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, + new HttpEntity<>(req, adminHeaders()), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int stock) { + var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, stock); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, + new HttpEntity<>(req, adminHeaders()), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "테스트유저", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/users", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private ResponseEntity> placeOrder(List items) { + return testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(new OrderV1Dto.CreateRequest(items, null), authHeaders()), + new ParameterizedTypeReference<>() {}); + } + + @DisplayName("POST /api/v1/orders") @Nested + class CreateOrder { + @DisplayName("단일 상품 주문이 성공한다") @Test + void createsSingleItemOrder() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var response = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().status()).isEqualTo("CREATED"), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @DisplayName("복수 상품 주문이 성공한다") @Test + void createsMultiItemOrder() { + Long brandId = createBrand("나이키"); + Long p1 = createProduct("에어맥스", 129000, brandId, 10); + Long p2 = createProduct("조던", 159000, brandId, 5); + signupMember(); + var response = placeOrder(List.of( + new OrderV1Dto.OrderItemRequest(p1, 2), new OrderV1Dto.OrderItemRequest(p2, 1))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(417000), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("재고 부족 시 400을 반환한다") @Test + void returns400WhenInsufficientStock() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 1); + signupMember(); + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 5)), null), authHeaders()), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더 누락 시 400을 반환한다") @Test + void returns400WhenNoAuth() { + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(1L, 1)), null)), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/orders") @Nested + class GetMyOrders { + @DisplayName("기간별 주문 목록을 조회한다") @Test + void returnsOrdersByDateRange() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + String today = LocalDate.now().toString(); + var response = testRestTemplate.exchange( + "/api/v1/orders?startAt=" + today + "&endAt=" + today, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") @Nested + class GetOrder { + @DisplayName("주문 상세를 조회한다") @Test + void returnsOrderDetail() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/orders") @Nested + class AdminGetOrders { + @DisplayName("관리자 주문 목록을 조회한다") @Test + void returnsAllOrders() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + var response = testRestTemplate.exchange("/api-admin/v1/orders?page=0&size=20", + HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") @Nested + class AdminGetOrder { + @DisplayName("관리자 주문 상세를 조회한다") @Test + void returnsOrderDetailForAdmin() { + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api-admin/v1/orders/" + orderId, + HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..d2ae0befe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -0,0 +1,354 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/products"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + private Long createBrand(String name) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, "설명"); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int initialStock) { + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/products") + @Nested + class CustomerGetProducts { + + @DisplayName("상품 목록을 조회하면 200과 재고상태를 포함하여 반환한다") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("나이키"); + Long adidasId = createBrand("아디다스"); + createProduct("에어맥스 90", 129000, nikeId, 100); + createProduct("슈퍼스타", 99000, adidasId, 50); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?brandId=" + nikeId + "&page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class CustomerGetProduct { + + @DisplayName("존재하는 상품을 조회하면 200과 재고상태를 포함하여 반환한다") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockStatus()).isNotNull() + ); + } + + @DisplayName("삭제된 상품을 조회하면 404를 반환한다") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + deleteProduct(productId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("미존재 상품을 조회하면 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/products") + @Nested + class AdminCreate { + + @DisplayName("유효한 정보로 등록하면 200과 재고수량을 포함하여 반환한다") + @Test + void returns200WithStockQuantity() { + // given + Long brandId = createBrand("나이키"); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스 90", "러닝화", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("삭제된 브랜드에 등록하면 404를 반환한다") + @Test + void returns404WhenBrandDeleted() { + // given + Long brandId = createBrand("나이키"); + deleteBrand(brandId); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "에어맥스 90", "러닝화", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class AdminGetAll { + + @DisplayName("삭제 포함하여 목록을 반환한다") + @Test + void returns200IncludingDeleted() { + // given + Long brandId = createBrand("나이키"); + createProduct("에어맥스 90", 129000, brandId, 100); + Long deletedId = createProduct("삭제될 상품", 99000, brandId, 10); + deleteProduct(deletedId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class AdminGetProduct { + + @DisplayName("존재하는 상품을 조회하면 200을 반환한다") + @Test + void returns200() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class AdminUpdate { + + @DisplayName("수정 성공 시 200과 변경된 정보를 반환한다") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "에어맥스 95", "뉴 러닝화", 159000 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스 95"), + () -> assertThat(response.getBody().data().description()).isEqualTo("뉴 러닝화"), + () -> assertThat(response.getBody().data().price()).isEqualTo(159000) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class AdminDelete { + + @DisplayName("삭제 성공 시 200을 반환한다") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("나이키"); + Long productId = createProduct("에어맥스 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("미존재 상품 삭제 시 404를 반환한다") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e6..aff2274cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -6,9 +6,9 @@ import static org.assertj.core.api.Assertions.assertThat; class CoreExceptionTest { - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @DisplayName("커스텀 메시지가 없으면 ErrorType의 메시지를 사용한다") @Test - void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + void usesErrorTypeMessageByDefault() { // arrange ErrorType[] errorTypes = ErrorType.values(); @@ -19,9 +19,9 @@ void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { } } - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") + @DisplayName("커스텀 메시지가 주어지면 해당 메시지를 사용한다") @Test - void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + void usesCustomMessageWhenProvided() { // arrange String customMessage = "custom message"; diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/docs/blog/ddd-responsibility-separation.md b/docs/blog/ddd-responsibility-separation.md new file mode 100644 index 000000000..0199843a1 --- /dev/null +++ b/docs/blog/ddd-responsibility-separation.md @@ -0,0 +1,634 @@ +![](https://velog.velcdn.com/images/praesentia-ykm/post/b3979ec4-a67a-4276-8c32-cd22d8e3cbbb/image.png) +## 들어가며 +[이전 글](https://velog.io/@praesentia-ykm)에서 33개의 Q&A로 설계를 먼저 한 이야기를 했다. 이번에는 그 설계 과정에서 가장 머리를 싸맸던 부분에 대해 써보려 한다. + +**"이 코드를 어디에 둬야 하는가?"** + +DDD를 공부하면 "바운디드 컨텍스트", "어그리게이트", "도메인 서비스" 같은 용어가 쏟아진다. 개념 자체는 어렵지 않았다. 문제는 **실제 코드에 적용하려 할 때** 발생했다. + +> "ProductService에 있어야 해, 아니면 ProductFacade에 있어야 해?" +> "Stock이랑 Product를 한 테이블에 두면 안 돼?" +> "LikeFacade가 ProductService를 직접 부르는 게 맞아?" + +이런 질문에 "상황에 따라 다릅니다"는 답이 되지 않는다. **어떤 상황에서 어떻게 달라지는지**, 그 갈림길의 기준을 찾고 싶었다. + +따라서, 이번엔 DDD 설계 흐름을 10개의 키워드로 정리하고, 각 키워드마다 **"어떤 기준으로 개념을 분리하는가"**에 대한 나의 생각을 표현해보려고 한다. + +--- + +## DDD 설계 흐름을 그려보자! + +| # | 단계 | 키워드 | 핵심 질문 | +|---|------|--------|----------| +| 1 | 전략 | 서브도메인 식별 | 이 사업의 핵심은 무엇이고, 어디에 설계 역량을 집중할 것인가? | +| 2 | 전략 | 유비쿼터스 언어 | 이 단어가 이 맥락에서 뭘 의미하나? | +| 3 | 전략 | 바운디드 컨텍스트 | 같은 단어가 다른 의미를 갖는 경계는? | +| 4 | 전략 | 컨텍스트 매핑 | 나눈 컨텍스트들이 서로 어떻게 대화하는가? | +| 5 | 전술 | 어그리게이트 | 이 데이터를 단독으로 다룰 일이 있는가? | +| 6 | 전술 | 엔티티 vs 값 객체 | 이것이 고유 정체성을 갖는가, 속성의 묶음인가? | +| 7 | 전술 | 도메인 이벤트 | 경계를 넘는 통신은 어떻게 하는가? | +| 8 | 전술 | 도메인 서비스 vs 애플리케이션 서비스 | 이 코드가 비즈니스 의사결정을 내리는가? | +| 9 | 전술 | 리포지토리 경계 | 어그리게이트 루트 단위로만 존재하는가? | +| 10 | 아키텍처 | 레이어 구분 | 의존성 방향이 안쪽을 향하는가? | + +![](https://velog.velcdn.com/images/praesentia-ykm/post/72623405-b0b5-484b-9ab8-5269dc0d3f63/image.png) + +1~4는 **"무엇을 나눌 것인가"**(전략), 5~9는 **"나눈 것을 어떻게 구현할 것인가"**(전술), 10은 **"구현물을 어떻게 배치할 것인가"**(아키텍처)다. + +한 가지 미리 짚어둘 게 있다. **나누기만 하면 부서진다.** 1~4에서 경계를 그으면, 5~9에서 그 경계 사이의 관계를 정의해야 한다. 나누기와 연결하기는 항상 쌍이다. + +--- + +## 전략적 설계 — 무엇을 나눌 것인가 + +### 1. 서브도메인 식별 — "이 중에 뭐가 제일 중요한가?" + +**키워드: Core / Supporting / Generic** + +서브도메인 식별은 "사업에서 어떤 영역이 있는가"를 나누고, **"어디에 설계 역량을 집중할 것인가"**를 결정하는 단계다. + +| 유형 | 의미 | 설계 전략 | +|------|------|-----------| +| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | +| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | +| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | + +이커머스에서 이걸 적용하면: + +| 서브도메인 | 유형 | 판단 근거 | +|-----------|------|----------| +| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | +| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님 | +| **좋아요** | Supporting | 고객 선호 추적. 카탈로그 정렬(인기순)에 활용 | +| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | +![](https://velog.velcdn.com/images/praesentia-ykm/post/97df448e-cc08-4ad8-a4eb-b4fdf5b87a0f/image.png) + + +근데 왜 재고가 Supporting이지? + +처음엔 이렇게 생각했다. 재고는 **"반응하는 도메인"**이다. 주문이 들어오면 차감되고, 상품이 등록되면 생성된다. 스스로 뭔가를 일으키기보다, 다른 도메인의 상태 변경에 영향을 받는 자식도메인 같은 느낌이었다. + +근데 이 기준만으로는 부족했다. 주문도 "고객이 구매 버튼을 누르면" 반응하는 도메인인데, Core잖아. "반응한다"는 것만으로 Supporting을 판별할 수 없었다. + +차이는 여기서 갈렸다: **"이 도메인이 없어도 사업이 성립하는가?"** + +- 주문이 없으면? 이커머스가 아니다. 물건을 팔 수 없다. +- 재고가 없으면? 판매는 된다. 다만 관리가 허술해질 뿐이다. 실제로 소규모 쇼핑몰은 재고 관리 없이도 돌아간다. + +**"반응만 하는 도메인 + 없어도 사업이 돌아가면 = Supporting."** 없으면 사업 자체가 불가능하면 아무리 반응형이어도 Core다. + +이 분류가 코드에 주는 영향은 명확하다. 카탈로그와 주문은 도메인 모델을 정교하게 설계하고, 회원/인증은 `userId`만 받아서 참조한다. 실제로 `MemberFacade`는 단순한 CRUD뿐이고, `OrderFacade`는 재고 차감, 스냅샷 생성, All or Nothing 검증까지 복잡한 규칙이 들어있다. + +--- + +### 2. 유비쿼터스 언어 — "이 단어가 이 맥락에서 뭘 의미하나?" + +**키워드: 같은 단어, 다른 의미** + +"상품이 뭔데?"라고 물었을 때, 대답이 달라지는 지점이 있다. + +현재 `ProductModel`은 이렇게 생겼다. + +```java +public class ProductModel extends BaseEntity { + private String name; // "상품을 전시한다" 관점 + private String description; // "상품을 전시한다" 관점 + private Money price; // "상품의 가치를 매긴다" 관점 + private Long brandId; // "상품이 어떤 브랜드인지" 관점 + private int likeCount; // "상품이 얼마나 인기있는지" 관점 +} +``` + +하나의 클래스에 다섯 가지 관심사가 공존한다. "상품이 뭔데?"라고 물으면 맥락마다 답이 완전히 다르다. + +| 맥락 | "상품"의 의미 | 관심 있는 속성 | 관심 없는 속성 | +|------|-------------|---------------|---------------| +| **카탈로그** | 고객에게 보여줄 전시물 | name, description, price, brand | quantity, likeCount | +| **재고** | 창고에서 관리할 물건 | productId, quantity, status | name, description, brand | +| **좋아요** | 사용자가 선호를 표현한 대상 | productId (참조만) | name, price, quantity | +| **주문** | 거래의 대상 (가격이 확정된 시점) | productId, 주문시점가격, 수량 | 현재가격, 재고, 좋아요 | + +같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이를 인식하는 것 자체가 유비쿼터스 언어를 정의하는 과정이다. + +그리고 이 과정은 다음 단계인 바운디드 컨텍스트와 **동시에** 일어난다. "상품"이라는 단어의 의미가 달라지는 지점을 발견하는 순간이 곧 경계를 긋는 순간이다. + +비유를 들자면, 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** + +--- + +### 3. 바운디드 컨텍스트 — "같은 단어가 다른 의미를 갖는 경계는?" + +**키워드: 경계 긋기** + +유비쿼터스 언어에서 의미가 갈라지는 지점이 바운디드 컨텍스트의 경계다. + +> **판별 기준:** 같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계 + +이 기준은 **동일 도메인 용어가 여러 맥락에서 사용될 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. + +재밌는 건 무의식적으로 이미 이 경계를 지키고 있었다는 것이다. + +- `LikeModel`은 `ProductModel`을 직접 참조하지 않고 `productId`만 보유한다. +- `StockModel`도 `productId`만 보유한다. +- `OrderItemModel`에는 주문 시점의 `productName`, `productPrice`를 **스냅샷**으로 복사한다. + +각 도메인은 "상품" 전체를 알 필요 없이 자기에게 필요한 단편만 들고 있다. 이것이 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**다. + +![](https://velog.velcdn.com/images/praesentia-ykm/post/6a57db0a-1182-4a68-bc92-c64b8ab6fde2/image.png) + +근데 "의미가 다르면 나눈다"로만 끝나지 않았다. **"이 둘이 하나의 트랜잭션으로 묶여야 하는가?"**도 경계 판단에 영향을 줬다. + +Brand와 Product가 그 예시다. 직감적으로는 분리하고 싶었다. 브랜드는 브랜드고 상품은 상품이니까. 근데 Q&A 과정에서 이런 질문을 던졌었다. + +> Q1: 브랜드를 삭제하면 소속 상품은 어떻게 되는가? + +답은 "브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete"이고, 이것은 **하나의 트랜잭션**으로 처리되어야 한다. + +```java +@Transactional +public void deleteBrand(Long brandId) { + brandService.delete(brandId); + productService.softDeleteByBrandId(brandId); // 연쇄 삭제 — 같은 트랜잭션 +} +``` + +만약 Brand와 Product가 다른 바운디드 컨텍스트에 있다면 이 트랜잭션은 **분산 트랜잭션**이 된다. "브랜드 삭제"라는 단순한 요구사항에 Saga 패턴 같은 복잡도를 도입하는 건 과하다. + +**경계 판단 기준 정리:** +1. 같은 단어가 다른 속성/행위를 요구하면 → 다른 BC +2. 하나의 트랜잭션으로 묶여야 하면 → 같은 BC + +![](https://velog.velcdn.com/images/praesentia-ykm/post/f0268056-f930-42c8-b0e7-d8bf89309f5d/image.png) + +--- + +### 4. 컨텍스트 매핑 — "나눈 것들이 어떻게 대화하는가?" + +**키워드: 관계 정의** + +바운디드 컨텍스트를 나눠 놓고 끝이 아니다. **나눈 것들 사이의 통신 방식을 정해야 한다.** 이걸 빼먹으면 "잘 나눈 것 같은데 결국 다 얽혀있네?"라는 상황이 된다. + +현재 프로젝트의 의존 관계: + +![](https://velog.velcdn.com/images/praesentia-ykm/post/d8132a29-13a3-415e-b604-2a50c5532bf8/image.png) + +모놀리스에서는 Facade가 다른 도메인의 Service를 직접 호출한다. 여기서 중요한 건 **"지금은 직접 호출하되, 시스템이 커졌을 때 어디서 잘라야 하는가"를 아는 것**이다. + +| 호출 | 방식 | 시스템 분리 시 전환 | +|------|------|-------------------| +| `ProductFacade` → `BrandService` | 직접 호출 | 같은 BC — 분리 불필요 | +| `ProductFacade` → `StockService` | 직접 호출 | API 호출 또는 이벤트 | +| `LikeFacade` → `ProductService` | 직접 호출 | **도메인 이벤트** | +| `OrderFacade` → `ProductService` + `StockService` | 직접 호출 | Saga 패턴 | + +특히 `LikeFacade`가 카탈로그 BC의 엔티티를 직접 수정하는 부분을 보면: + +```java +// LikeFacade — 좋아요 BC가 카탈로그 BC의 엔티티를 직접 수정 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + // ... 좋아요 로직 + product.incrementLikeCount(); // ← 다른 BC의 엔티티를 직접 변경 +} +``` + +모놀리스에서는 이게 실용적이다. 하지만 이 코드가 **BC 경계를 넘는 직접 수정**이라는 사실은 인식하고 있어야 한다. 시스템이 커져서 물리적으로 분리할 때, 이 부분은 도메인 이벤트로 전환된다. + +> 좋아요 발생 → `LikeCreatedEvent` 발행 → 카탈로그 BC가 수신 → `likeCount` 갱신 + +"지금은 직접 호출하되, 여기가 나중에 잘라야 할 지점"이라는 걸 아는 것과 모르는 것은 다르다. 컨텍스트 매핑의 가치가 여기에 있다. + +--- + +## 전술적 설계 — 나눈 것을 어떻게 구현할 것인가 + +### 5. 어그리게이트 — "단독으로 접근할 일이 있는가?" + +**키워드: 접근성과 잠금** + +같은 바운디드 컨텍스트 안에서도 "어디까지를 하나의 단위로 묶을 것인가"를 결정해야 한다. 이게 어그리게이트 경계다. + +Product와 Stock은 1:1 관계인데, 깊은 관계니까 하나로 합쳐야 하지 않을까? 처음엔 그렇게 생각했다. 근데 직감적으로 **"재고를 보기 위해 매번 상품을 거쳐야 한다면?"**이 걸렸다. 재고 차감은 상품 정보가 필요 없는데, 상품을 통해서만 접근해야 하면 비효율적이지 않은가. + +이 직감을 기준으로 정리하면: + +| 케이스 | "단독으로 접근할 일 있나?" | 결론 | +|--------|:---:|------| +| 재고 ← 상품 | 있다 (재고만 차감) | 분리 | +| 상품 ← 브랜드 | 있다 (상품만 조회) | 분리 | +| 주문항목 ← 주문 | 없다 (항상 주문 통해) | 합침 | + +이 기준은 DDD에서 흔히 쓰는 **"같이 잠글 필요가 있는가?"**와 결국 같은 얘기다. + +| 변경 시나리오 | Product 변경? | Stock 변경? | 결론 | +|-------------|:----------:|:----------:|------| +| 상품명 수정 | O | X | 독립 | +| 가격 수정 | O | X | 독립 | +| 재고 차감 (주문) | X | O | 독립 | +| 상품 등록 (초기 재고 포함) | O | O | Facade에서 조율 | + +![](https://velog.velcdn.com/images/praesentia-ykm/post/d113df10-a09f-4601-aa25-ee039d93a65c/image.png) + + +단독으로 접근이 필요하다는 건 곧 독립적으로 변경된다는 뜻이고, 독립적으로 변경되면 같이 잠글 필요가 없다. **접근성에서 출발해도 잠금에서 출발해도 같은 결론에 도달한다.** + +유일하게 둘 다 변경되는 "상품 등록"은 **비즈니스 규칙이 아니라 절차**다. "상품을 등록하면서 초기 재고도 만든다"는 순서의 문제이지, 둘이 반드시 원자적으로 잠겨야 하는 건 아니다. Facade에서 조율하면 된다. + +```java +@Transactional +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 + ProductModel product = productService.register(...); // 2. 상품 생성 + stockService.create(product.getId(), initialStock); // 3. 재고 생성 + return product; +} +``` + +> **단독으로 접근할 일이 있으면 별도 어그리게이트. 동시 변경이 필요한 경우는 Facade에서 조율.** + +--- + +### 6. 엔티티 vs 값 객체 — "이것이 고유 정체성을 갖는가?" + +**키워드: 정체성 유무** + +어그리게이트 안의 객체는 엔티티(Entity)와 값 객체(Value Object)로 나뉜다. 판별 기준은 하나다. + +> **고유한 식별자(id)가 필요한가? → 엔티티. 속성 값이 같으면 같은 것인가? → 값 객체.** + +"어떤 5000원"인지가 중요하면 엔티티다. "5000원이면 다 같은 5000원"이면 값 객체다. + +```java +// 값 객체 — 5000원이면 다 같은 5000원 +@Embeddable +public class Money { + public static final Money ZERO = new Money(0); + + @Column(name = "price", nullable = false) + private int value; + + public Money(int value) { + if (value < 0) throw new CoreException(ErrorType.BAD_REQUEST, "가격은 음수일 수 없습니다."); + this.value = value; + } + + public Money add(Money other) { return new Money(this.value + other.value); } + public Money multiply(int multiplier) { return new Money(this.value * multiplier); } +} +``` + +```java +// 값 객체 — 같은 이름이면 같은 브랜드명 +@Embeddable +public class BrandName { + @Column(name = "name", nullable = false, unique = true) + private String value; + + public BrandName(String value) { + if (value == null || value.isBlank()) + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + this.value = value; + } +} +``` + +값 객체의 특징 세 가지: +1. **불변(Immutable):** `add()`는 기존 객체를 바꾸지 않고 새 객체를 반환한다 +2. **자기 검증:** 생성 시점에 유효성을 강제한다 (음수 불가, 빈값 불가) +3. **행위 포함 가능:** `Money.multiply()`처럼 해당 값과 관련된 연산을 가질 수 있다 + +현재 프로젝트의 분류: + +| 객체 | 타입 | 근거 | +|------|------|------| +| `ProductModel` | 엔티티 | 고유 id로 식별. "상품 A"와 "상품 B"는 속성이 같아도 다른 상품 | +| `Money` | 값 객체 | 5000원은 어떤 5000원이든 같은 5000원 | +| `BrandName` | 값 객체 | "나이키"라는 이름은 그 자체로 동일한 의미 | +| `MemberName` | 값 객체 | 이름 값 자체가 의미. `masked()` 행위 메서드 보유 | +| `LoginId` | 값 객체 | 영문+숫자 패턴 검증을 생성 시 강제 | + +--- + +### 7. 도메인 이벤트 — "경계를 넘는 통신을 어떻게 하는가?" + +**키워드: 비동기 통신 / 결합도 제거** + +4번(컨텍스트 매핑)에서 "나눈 것들이 어떻게 대화하는가"를 정했다면, 도메인 이벤트는 그 대화의 **구현 수단** 중 하나다. + +현재 프로젝트에는 도메인 이벤트가 **하나도 없다.** 모든 컨텍스트 간 통신은 Facade에서 직접 호출한다. + +```java +// 현재: LikeFacade가 ProductService를 직접 호출 +product.incrementLikeCount(); + +// 도메인 이벤트 도입 시: +// 좋아요 생성 → LikeCreatedEvent 발행 → 카탈로그 BC가 수신 → likeCount 갱신 +``` + +왜 지금 도입하지 않았을까? 트레이드오프 판단이었다. + +| 기준 | 직접 호출 | 도메인 이벤트 | +|------|----------|-------------| +| 구현 복잡도 | 낮음 | 높음 (이벤트 발행/구독 인프라 필요) | +| 정합성 | 즉시 정합성 | 최종 정합성 (eventual consistency) | +| 결합도 | 높음 (다른 BC 서비스 직접 참조) | 낮음 (이벤트만 알면 됨) | +| 적합한 규모 | 모놀리스 | 마이크로서비스 | + +![](https://velog.velcdn.com/images/praesentia-ykm/post/140597e1-5b12-404b-97b1-9bb7ee0e5cde/image.png) + +모놀리스에서 도메인 이벤트를 도입하면 "아직 필요 없는 복잡도"가 된다. 다만 **어디가 이벤트로 전환될 지점인지**는 컨텍스트 매핑(4번)에서 이미 식별해뒀다. 필요해지는 시점에 전환하면 된다. + +--- + +### 8. 도메인 서비스 vs 애플리케이션 서비스 — "이 코드가 비즈니스 의사결정을 내리는가?" + +**키워드: 의사결정 vs 조율** + +"이 로직을 `domain/`에 둘까, `application/`에 둘까?" — 설계하면서 가장 오래 고민한 질문이다. + +처음에 세운 기준은 이거였다. + +> **규칙은 영업부에 설명해도 알아들을 수 있는 것. 절차는 개발자들만 고민하고 구성해야 하는, 사용자 요청에 대한 시나리오.** + +"같은 이름의 브랜드는 등록할 수 없다" — 영업부도 안다. 규칙이다. → `BrandService` +"브랜드 확인하고, 상품 만들고, 재고 만들어라" — 영업부는 모른다. 개발자가 짠 순서다. 절차다. → `ProductFacade` + +여기까지는 잘 작동했다. 그리고 `BrandService`는 `domain/` 패키지에 넣었다. "규칙을 담으니까 도메인 서비스"라고 생각했다. + +**근데 이 판단이 틀렸다.** + +리팩토링 과정에서 더 날카로운 기준을 만났다. + +> **"이 코드가 비즈니스 의사결정을 내리는가?"** + +이 질문을 `BrandService.register()`에 대입해봤다. + +```java +public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // ← VO가 이름 유효성 검증 (의사결정) + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← DB 상태 확인 후 거부 (조율) + }); + return brandRepository.save(new BrandModel(brandName, description)); +} +``` + +**"브랜드 이름이 비어있으면 안 된다"** — 이건 `BrandName` VO가 생성 시점에 스스로 결정한다. 의사결정이다. +**"중복 이름이 있는지 DB에서 확인한다"** — 이건 `BrandService`가 Repository를 호출해서 상태를 확인하는 것이다. 조율이다. + +핵심은 이거였다. `BrandService`는 **의사결정을 내리는 게 아니라, 엔티티/VO가 내린 의사결정이 실행될 수 있도록 정보를 준비하고 조율하는 것**이다. 3단계로 보면: + +1. **정보 준비** (application) — Repository에서 데이터 조회 +2. **비즈니스 의사결정** (domain) — 엔티티/VO가 규칙을 적용 +3. **결과 적용** (application) — 저장, 이벤트 발행 등 + +`BrandService`의 "유니크 검증"도 결국 1→3이다. DB 상태를 확인(정보 준비)하고 → 충돌이면 거부(결과 적용). **비즈니스 의사결정 자체는 `BrandName` VO가 이미 담당하고 있다.** + +이 기준으로 현재 프로젝트의 모든 Service를 점검했다. + +| Service | 하는 일 | 의사결정을 내리는가? | 결론 | +|---------|--------|:---:|------| +| `BrandService` | 이름 유니크 체크 + CRUD | No (VO가 유효성 검증) | **애플리케이션 서비스** | +| `ProductService` | 상품 CRUD | No (Money VO가 가격 검증) | **애플리케이션 서비스** | +| `StockService` | 재고 생성/차감 | No (StockModel.decrease()가 판단) | **애플리케이션 서비스** | +| `LikeService` | 좋아요 등록/취소 | No (멱등성은 DB 상태 확인) | **애플리케이션 서비스** | +| `OrderService` | 주문/주문상품 CRUD | No (OrderModel.validateOwner()가 판단) | **애플리케이션 서비스** | + +**도메인 서비스가 하나도 없었다.** 모든 비즈니스 의사결정은 엔티티와 VO가 내리고 있었고, Service는 그 결정이 실행되도록 조율하는 역할이었다. 그래서 전부 `domain/` → `application/`으로 이동시켰다. + +![](https://velog.velcdn.com/images/praesentia-ykm/post/adf659f8-c0a3-4586-99b5-fea7c266d28e/image.png) + +그럼 `application/` 안에서 **Service와 Facade는 어떻게 구분하는가?** + +| 구분 | Service | Facade | +|------|---------|--------| +| **역할** | 단일 도메인 CRUD 조율 | 여러 도메인 Service를 조합 | +| **의존** | Repository (하나) | Service (여러 개) | +| **예시** | `BrandService` (브랜드 CRUD) | `ProductFacade` (브랜드 확인 + 상품 생성 + 재고 생성) | + +코드로 보면: + +**엔티티 메서드 — 비즈니스 의사결정을 내린다:** + +```java +// "재고가 부족하면 차감할 수 없다" — StockModel이 스스로 판단 +public void decrease(int amount) { + if (this.quantity < amount) + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + this.quantity -= amount; +} +``` + +**Service (애플리케이션) — 단일 도메인 CRUD를 조율한다:** + +```java +// application/brand/BrandService: Repository를 호출하여 CRUD 수행 +// 의사결정은 BrandName VO가 내리고, Service는 DB 상태 확인 + 저장을 조율 +public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // VO가 이름 유효성 검증 + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // DB 상태 확인 후 거부 + }); + return brandRepository.save(new BrandModel(brandName, description)); +} +``` + +**Facade (애플리케이션) — 여러 도메인 Service를 조합한다:** + +```java +// application/product/ProductFacade: 여러 Service를 조합하여 유스케이스 실행 +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) + ProductModel product = productService.register(...); // 2. 상품 생성 (위임) + stockService.create(product.getId(), initialStock); // 3. 재고 생성 (위임) + return product; +} +``` + +**Facade에 규칙이 있을 수도 있다:** + +```java +// "브랜드 삭제 시 소속 상품도 삭제" — 영업부도 아는 규칙이지만, 두 도메인에 걸침 +@Transactional +public void deleteBrand(Long brandId) { + brandService.delete(brandId); + productService.softDeleteByBrandId(brandId); // ← 규칙이지만, 여러 도메인이라 Facade +} +``` + +이전에 "규칙이면 도메인 서비스"라고 단순하게 분류했던 것이 틀렸다. **핵심 기준은 "규칙인가 절차인가"가 아니라 "비즈니스 의사결정을 내리는가"다.** 대부분의 의사결정은 엔티티/VO 안에 캡슐화되어 있지만, 하나 발견했다. + +**LikeFacade에 숨어있던 도메인 서비스:** + +좋아요 멱등성 로직은 `LikeModel`과 `ProductModel` 두 엔티티의 상태를 종합해서 판단한다. "좋아요가 없으면 생성, 삭제됐으면 복구, 이미 있으면 무시" — 이 판단을 어느 한쪽 엔티티에 넣을 수 없다. `LikeModel`은 `ProductModel`을 모르고, `ProductModel`은 `LikeModel`을 모른다. + +이 로직을 지우면 **"멱등성 있는 좋아요"라는 비즈니스 규칙 자체가 사라진다.** 조율이 아니라 의사결정이다. 그래서 도메인 서비스로 분리했다. + +```java +// domain/like/LikeToggleService — 도메인 서비스 +// Like + Product 두 엔티티의 상태를 종합하여 판단. 인프라 의존 없음. +public Optional like(Optional existing, ProductModel product, + Long userId, Long productId) { + if (existing.isEmpty()) { // 판단: 신규 → 생성 + 카운트 증가 + product.incrementLikeCount(); + return Optional.of(new LikeModel(userId, productId)); + } + if (existing.get().getDeletedAt() != null) { // 판단: 삭제됨 → 복구 + 카운트 증가 + existing.get().restore(); + product.incrementLikeCount(); + } + // else: 이미 활성 → 멱등 무시 + return Optional.empty(); +} +``` + +```java +// application/like/LikeFacade — 애플리케이션 서비스 (조율만) +// 데이터 조회 → 도메인 서비스에 판단 위임 → 결과 저장 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + Optional newLike = likeToggleService.like(existing, product, userId, productId); + newLike.ifPresent(likeService::save); +} +``` + +**도메인 서비스가 필요한 조건:** 두 개 이상의 어그리게이트에 걸친 비즈니스 의사결정을 내려야 하는데, 어느 한쪽 엔티티에 넣기 어려운 경우다. `LikeToggleService`가 정확히 이 케이스다. + +**범위 한정:** 이 "의사결정" 기준은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. + +--- + +### 9. 리포지토리 경계 — "어그리게이트 루트 단위로만 존재하는가?" + +**키워드: 어그리게이트 루트 = 리포지토리 단위** + +DDD의 원칙: **리포지토리는 어그리게이트 루트 하나당 하나.** 어그리게이트 내부의 엔티티는 루트를 통해서만 접근한다. + +근데 이 원칙을 의도적으로 깬 곳이 있다. + +```java +// OrderService — OrderItemRepository가 별도로 존재 +public class OrderService { + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; // ← 원칙대로라면 없어야 함 +} +``` + +DDD 정석으로는 `OrderItem`은 `Order` 어그리게이트의 내부 엔티티이므로, `OrderRepository`를 통해서만 접근해야 한다. 그러려면 JPA 연관관계(`@OneToMany`)가 필요하다. 근데 이 프로젝트는 ID 참조만 쓴다. + +왜 JPA 연관관계 대신 ID 참조를 택했을까? 두 방식의 차이를 보면: + +| 기준 | ID 참조 | JPA 연관관계 (`@OneToMany`) | +|------|---------|---------------------------| +| 조회 | `findByOrderId(id)` — 명시적 | `order.getItems()` — 암시적 lazy loading | +| N+1 문제 | 없음 | 있음 (fetch join 필요) | +| 양방향 동기화 | 불필요 | 필수 (`item.setOrder(this)` 빠뜨리면 버그) | +| 삭제 | 명시적 delete 호출 | `orphanRemoval`이면 리스트에서 빼는 것만으로 삭제 | +| 테스트 | ID만 넣으면 됨 | 전체 객체 그래프 구성 필요 | + +JPA 연관관계의 복잡도는 **실제로 코드에서 터지는 문제들**이다. N+1은 성능 이슈를, 양방향 동기화 누락은 버그를, 암시적 삭제는 데이터 유실을 만든다. + +반대로 DDD 리포지토리 원칙을 깨면 뭐가 터질까? "누군가 `OrderItemRepository`를 직접 호출할 수 있다"는 위험이 생긴다. 하지만 현재는 `OrderService`가 두 Repository를 모두 들고 있고, `OrderItem` 접근은 항상 `OrderService`를 통한다. **서비스 레이어가 접근을 통제하고 있으므로 실질적 위험은 낮다.** + +원칙을 깨도 되는지 판단할 때 세 가지를 물었다: + +1. **깨면 실제로 터지는가?** — JPA 연관관계는 실제 버그를 만든다 → 피한다 +2. **다른 수단으로 보호 가능한가?** — 서비스 레이어가 접근 통제 중 → 보호됨 +3. **그 보호를 조직이 유지할 수 있는가?** — 현재 규모에서 감당 가능 → 깨도 된다 + +이 판단은 영원히 유효하진 않다. 팀이 커지면 3번 조건이 깨질 수 있고, 그때는 다른 보호 수단을 마련해야 한다. + +--- + +## 아키텍처 — 구현물을 어떻게 배치할 것인가 + +### 10. 레이어 구분 — "의존성 방향이 안쪽을 향하는가?" + +**키워드: 의존성 방향** + +전략적/전술적 설계가 끝난 후 코드로 옮기는 단계다. 각 레이어의 역할과 의존 방향을 정한다. + +![](https://velog.velcdn.com/images/praesentia-ykm/post/4016c78a-3e3a-4136-a03f-c7b22602d70a/image.png) + + +핵심 규칙: **의존성은 항상 안쪽(domain)을 향한다.** + +- Controller는 Facade/Service(application)를 알지만, application은 Controller를 모른다 +- Controller는 domain을 직접 참조하지 않는다. Dto도 application DTO(Info/Result record)를 경유한다 +- Facade는 여러 Service를 알지만, Service는 Facade를 모른다 +- Service는 Repository Interface를 알지만, JPA 구현체를 모른다 + +Repository Interface가 `domain/` 패키지에 있는 이유가 여기에 있다. Domain Layer는 "데이터를 저장하고 조회하는 능력"이 필요하지만, "그것이 JPA로 구현되는지 MyBatis로 구현되는지"는 알 필요가 없다. Interface를 domain에 두고 구현체를 infrastructure에 두면, domain은 기술 선택에 의존하지 않는다. + +--- + +## 최종 설계 + +10가지 키워드를 적용한 결과물이다. + +### 도메인 — 비즈니스 의사결정 + +| 컴포넌트 | 담당하는 의사결정 | +|---------|---------| +| `BrandName` (VO) | 브랜드 이름 유효성 (비어있으면 안 됨) | +| `Money` (VO) | 가격 유효성 (음수 불가), 연산 (`add`, `multiply`) | +| `LoginId` (VO) | 로그인 ID 형식 검증 (영문+숫자 패턴) | +| `MemberName` (VO) | 이름 유효성, 마스킹 (`masked()`) | +| `StockModel` (Entity) | 재고 충분 여부 판단, 차감/증가 (`decrease`, `increase`) | +| `OrderModel` (Entity) | 주문 소유권 검증 (`validateOwner`), 상태 전이 | +| `ProductModel` (Entity) | likeCount 증감, 삭제 상태 검증 | +| `LikeToggleService` (도메인 서비스) | Like + Product 두 엔티티의 상태를 종합하여 좋아요 반응 결정 (신규/복구/멱등 무시) | + +대부분의 비즈니스 의사결정은 엔티티와 VO 안에 캡슐화되어 있다. 하지만 **좋아요 멱등성 판단**은 `LikeModel`과 `ProductModel` 두 엔티티의 상태를 종합해야 하므로 어느 한쪽 엔티티에 넣을 수 없다. 이것이 `LikeToggleService` 도메인 서비스가 필요한 이유다. + +### 애플리케이션 — 조율 (Service + Facade) + +**Service — 단일 도메인 CRUD 조율:** + +| 컴포넌트 | 조율 내용 | +|---------|---------| +| `BrandService` | 브랜드명 유니크 체크 + CRUD (이름 유효성은 `BrandName` VO가 판단) | +| `ProductService` | 상품 CRUD, likeCount 증감, soft delete | +| `StockService` | 재고 생성, 차감 (부족 여부는 `StockModel`이 판단) | +| `LikeService` | 좋아요 저장/조회 (멱등성 판단은 `LikeToggleService` 도메인 서비스가 담당) | +| `OrderService` | 주문/주문상품 CRUD (소유권 검증은 `OrderModel`이 판단) | +| `MemberSignupService` | 회원가입 (ID 중복 체크 + 생성, 형식 검증은 `LoginId` VO가 판단) | +| `MemberAuthService` | 인증 (비밀번호 검증은 엔티티에 위임) | +| `MemberPasswordService` | 비밀번호 변경 (검증은 엔티티/VO에 위임) | + +**Facade — 여러 도메인 Service를 조합:** + +| 컴포넌트 | 조율 내용 | 성격 | +|---------|---------|------| +| `BrandFacade` | 브랜드 삭제 → 소속 상품 연쇄 soft delete | 규칙 (여러 도메인에 걸침) | +| `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | 절차 | +| `LikeFacade` | 데이터 조회 → `LikeToggleService`에 판단 위임 → 결과 저장 | 절차 | +| `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | 규칙 (여러 도메인에 걸침) | +| `MemberFacade` | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | 절차 | + +Service와 Facade 모두 `application/` 레이어에 있다. 차이는 **의존 범위**다. Service는 자기 도메인의 Repository 하나만 의존하고, Facade는 여러 Service를 조합한다. 어느 쪽이든 비즈니스 의사결정 자체는 엔티티/VO가 내리고, application 레이어는 그 결정이 실행되도록 정보를 준비하고 결과를 적용한다. + +--- + +## 회고: 설계엔 정답은 없지만 오답은 있다. + +10개의 키워드는 결국 두 가지 행위의 반복이었다: **나누기**와 **연결하기**. + +전략적 설계(1~4)에서 비즈니스 영역을 나누고 그 관계를 정의한다. 전술적 설계(5~9)에서 코드 단위를 나누고 그 통신 방식을 정의한다. 아키텍처(10)에서 배치를 나누고 의존 방향을 정의한다. + +어느 단계에서든 "나누기만 하고 연결하기를 빼먹으면" 시스템이 부서진다. 바운디드 컨텍스트를 나누고 컨텍스트 매핑을 안 하면 "잘 나눈 것 같은데 결국 다 얽혀있네?"가 된다. 어그리게이트를 나누고 Facade 조율을 안 하면 "각각은 깔끔한데 전체 유스케이스가 안 돌아가네?"가 된다. + +이 과정을 겪으면서 의외였던 것이 세 가지 있었다. + +첫째, **책임 분리는 결국 비즈니스가 결정한다.** 서브도메인은 "없어도 사업이 돌아가는가?"로, 어그리게이트는 "단독으로 접근할 비즈니스 시나리오가 있는가?"로 갈린다. 기술적 판단처럼 보이는 것도 출발점은 비즈니스였다. + +둘째, **"규칙을 담는다"와 "의사결정을 내린다"는 다르다.** 처음에 `BrandService`가 "중복 이름 검증"이라는 규칙을 담고 있으니 도메인 서비스라고 생각했다. 하지만 리팩토링 후 깨달았다. Service가 하는 건 DB 상태를 확인하고 결과를 적용하는 **조율**이다. 실제 의사결정("이 이름이 유효한가?")은 `BrandName` VO가 내린다. "규칙이 있으면 도메인 서비스"가 아니라 **"비즈니스 의사결정을 직접 내리면 도메인 서비스"**다. 이 차이를 모르면 Service를 전부 `domain/`에 두는 실수를 하게 된다 — 실제로 내가 그랬다. + +셋째, **"도메인 서비스가 없다"는 의심해봐야 한다.** 처음에 모든 Service를 application으로 옮기고 "도메인 서비스가 하나도 없다"고 결론 내렸다. 근데 곰곰이 보니 `LikeFacade.like()`의 if/else 분기가 비즈니스 의사결정이었다. 지우면 규칙이 사라지는 코드가 application 레이어에 있었던 것이다. "지우면 뭐가 사라지는가?"를 물었더니 숨어있던 도메인 서비스가 드러났다. + +넷째, **원칙은 깰 수 있다. 단, 조건이 있다.** 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다. + +오늘도 느낀 바지만 역시 "은탄환은 없다" 하지만 시작은 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **키워드를 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라질 수 있다고 생각한다. diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md new file mode 100644 index 000000000..d76c8cec5 --- /dev/null +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -0,0 +1,355 @@ +# DDD 설계 프레임워크 + +> 이 프로젝트의 도메인 설계 시 참조하는 사고 프레임워크. +> 실제 설계 과정에서 흐름을 검토하고, DDD 정석과 대조하여 조정한 결과물이다. + +--- + +## 1. DDD 설계 흐름 (수정 전 vs 수정 후) + +### 원래 흐름 + +``` +1. 최상위 도메인 구분 +2. 유비쿼터스 언어 구분 +3. 바운디드 컨텍스트 구분 +4. 루트 어그리게이트 +5. 레이어 구분 +6. 도메인 서비스 / 비즈니스 서비스 +``` + +### 조정된 흐름 (DDD 정석) + +``` +1. 서브도메인 식별 + 분류 (Core / Supporting / Generic) +2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) +3. 컨텍스트 매핑 (관계 정의) +4. 전술적 설계 (어그리게이트 + 엔티티/VO + 도메인 이벤트) +5. 레이어드 아키텍처 (구현) +6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) +``` + +### 대조표 + +``` +수정 전 수정 후 +───────────────── ───────────────── +1. 최상위 도메인 구분 → 1. 서브도메인 식별 + 분류 (Core/Supporting/Generic) +2. 유비쿼터스 언어 구분 ┐ 2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) +3. 바운디드 컨텍스트 구분 ┘ + (빠짐) → 3. 컨텍스트 매핑 (관계 정의) +4. 루트 어그리게이트 → 4. 전술적 설계 (어그리게이트 + 엔티티/VO + 도메인 이벤트) +5. 레이어 구분 → 5. 레이어드 아키텍처 (구현) +6. 도메인 서비스/비즈니스 서비스 → 6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) +``` + +**3가지 핵심 조정 사항:** +1. **2번과 3번은 분리된 단계가 아니라 하나의 동시 과정** — 언어 차이를 발견하는 것이 곧 경계를 긋는 것 +2. **컨텍스트 매핑이 누락** — 나눈 컨텍스트들의 통신 방식을 정의해야 함 +3. **"비즈니스 서비스"는 DDD 용어가 아님** — "애플리케이션 서비스"로 정정 + +--- + +## 2. 각 단계 상세 + +### 2-1. 서브도메인 식별 + 분류 + +> "이 사업에서 뭘 하는가"를 영역별로 쪼개고, 어디에 설계 역량을 집중할지 결정한다. + +"최상위 도메인"이라는 표현은 방향은 맞지만, DDD에서는 **서브도메인(Subdomain)**이라는 더 구체적인 분류 기준을 사용한다. + +**서브도메인의 3가지 유형:** + +| 유형 | 의미 | 설계 전략 | +|------|------|-----------| +| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | +| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | +| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | + +이 분류가 왜 필요한가: Core 서브도메인은 직접 정교하게 설계하고, Generic은 외부 솔루션을 쓸 수 있다. +"어디에 시간을 쓸 것인가"의 판단 근거가 된다. + +### 2-2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) + +> "상품이 뭔데?"라고 물었을 때 대답이 달라지는 지점이 곧 경계다. +> 언어 차이를 발견하는 것과 경계를 긋는 것은 같은 행위의 양면이다. + +**비유:** 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것. "여기서부터 이 언어가 통하지 않는다"를 발견하는 순간이 곧 국경선이 그어지는 순간이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** + +**판별 기준:** "같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계" + +**적용 범위 한정:** 이 기준은 **동일 도메인 용어가 여러 맥락에서 사용될 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. + +### 2-3. 컨텍스트 매핑 + +> 바운디드 컨텍스트를 나눴으면, "얘네가 서로 어떻게 대화하는가"를 정해야 한다. + +현재 프로젝트에서 **Facade가 이 역할을 수행**하고 있다. 모놀리스에서는 직접 호출이 실용적이지만, 시스템이 커졌을 때 **어디서 잘라야 하는가**를 미리 인식하는 단계다. + +### 2-4. 전술적 설계 + +> 바운디드 컨텍스트 안에서 결정하는 것들. + +| 결정 사항 | 질문 | 프로젝트 예시 | +|-----------|------|--------------| +| **어그리게이트 경계** | 어디까지가 하나의 트랜잭션 단위인가? | Product와 Stock은 별도 어그리게이트 | +| **엔티티 vs 값 객체** | 이 객체에 고유 식별자가 필요한가? | `Money`는 VO, `ProductModel`은 엔티티 | +| **도메인 이벤트** | 컨텍스트 간 통신은 어떻게? | 좋아요 생성 → likeCount 갱신 | + +### 2-5. 레이어드 아키텍처 (구현) + +전략적/전술적 설계가 끝난 후 코드로 옮기는 단계. + +``` +interfaces/ → 외부 요청 수신 (Controller, DTO) +application/ → 유스케이스 조율 (Facade, Service) +domain/ → 비즈니스 규칙 (Entity, VO, Repository 인터페이스) +infrastructure/ → 기술 구현 (JpaRepository) +``` + +**Controller 의존 규칙:** +- Controller → Facade 또는 Service (application 레이어만 의존) +- Controller ✗ domain 직접 참조 금지 +- Dto는 application DTO(Info/Result record)로부터 변환 + +### 2-6. 도메인 서비스 vs 애플리케이션 서비스 + +> "비즈니스 서비스"는 DDD 용어가 아니다. 구분하려는 것은 아래 두 가지다. + +| | 도메인 서비스 | 애플리케이션 서비스 | +|---|---|---| +| **현재 프로젝트** | `domain/` (현재 해당 없음) | `application/XxxService` + `application/XxxFacade` | +| **담는 것** | 비즈니스 **규칙** (엔티티 하나로 표현 불가한) | 유스케이스 **절차** (CRUD 조율 + 다중 도메인 조합) | +| **판별 질문** | "이 코드가 비즈니스 의사결정을 내리는가?" → Yes → 도메인 서비스 | "의사결정을 조율하고 외부와 상호작용하는가?" → Yes → 애플리케이션 서비스 | + +**적용 범위:** 이 "규칙 vs 절차" 구분은 **도메인 계층과 애플리케이션 계층 사이**에서만 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다. + +--- + +## 3. 현재 프로젝트의 서브도메인 분류표 + +| 서브도메인 | 유형 | 근거 | +|-----------|------|------| +| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | +| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님 | +| **좋아요** | Supporting | 고객 선호 추적. 카탈로그 정렬(인기순)에 활용 | +| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | + +**Member가 다른 도메인과 완전히 독립적인 이유:** +회원 인증은 Generic 서브도메인이다. 다른 비즈니스 로직과 결합될 이유가 없으며, 현재 프로젝트에서도 `userId`만으로 참조하고 있다. + +--- + +## 4. 바운디드 컨텍스트 발견 과정 + +### "상품이 뭔데?" — 같은 단어, 다른 의미 + +현재 `ProductModel`에 모든 관심사가 한 엔티티에 모여있다: + +```java +public class ProductModel extends BaseEntity { + private String name; // ← "상품을 전시한다" 관점 + private String description; // ← "상품을 전시한다" 관점 + private Money price; // ← "상품의 가치를 매긴다" 관점 + private Long brandId; // ← "상품이 어떤 브랜드인지" 관점 + private int likeCount; // ← "상품이 얼마나 인기있는지" 관점 +} +``` + +"상품이 뭔데?"라고 물으면 맥락마다 답이 다르다: + +| 맥락 | "상품"의 의미 | 관심 있는 속성 | 관심 없는 속성 | +|------|-------------|---------------|---------------| +| **카탈로그** | 고객에게 보여줄 전시물 | name, description, price, brand | quantity, likeCount | +| **재고** | 창고에서 관리할 물건 | productId, quantity, status | name, description, brand | +| **좋아요** | 사용자가 선호를 표현한 대상 | productId (참조만) | name, price, quantity | +| **주문** | 거래의 대상 (가격이 확정된 시점) | productId, 주문시점가격, 수량 | 현재가격, 재고, 좋아요 | + +같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이가 바운디드 컨텍스트의 경계다. + +### 무의식적으로 이미 적용하고 있는 경계 + +- `LikeModel`이 `ProductModel`을 직접 참조하지 않고 `productId`만 보유 +- `StockModel`도 `productId`만 보유 +- `OrderItemModel`에 주문 시점의 `productName`, `productPrice`를 **스냅샷**으로 복사 + +이것이 바로 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**이다. + +### 프로젝트의 바운디드 컨텍스트 + +```mermaid +graph LR + subgraph "카탈로그 BC" + Brand["Brand (어그리게이트)"] + Product["Product (어그리게이트)"] + end + subgraph "재고 BC" + Stock["Stock (어그리게이트)"] + end + subgraph "좋아요 BC" + Like["Like (어그리게이트)"] + end + subgraph "주문 BC" + Order["Order (어그리게이트)"] + OrderItem["OrderItem (엔티티)"] + end + subgraph "회원/인증 BC" + Member["Member"] + end + + Product -- "brandId (ID 참조)" --> Brand + Stock -- "productId (ID 참조)" --> Product + Like -- "productId (ID 참조)" --> Product + Like -- "userId (ID 참조)" --> Member + Order -- "userId (ID 참조)" --> Member + OrderItem -- "productId + 스냅샷" --> Product +``` + +**브랜드와 상품이 같은 BC인 근거:** +브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete하는 것이 **하나의 트랜잭션**으로 처리된다(Q1). 이 트랜잭션 경계가 같은 BC에 속해야 하는 직접적인 이유다. + +--- + +## 5. 컨텍스트 매핑 + +### 의존 방향 + +```mermaid +graph TD + Auth["회원/인증 BC
(Generic)"] + Catalog["카탈로그 BC
(Core)"] + Inventory["재고 BC
(Supporting)"] + LikeCtx["좋아요 BC
(Supporting)"] + OrderCtx["주문 BC
(Core)"] + + LikeCtx -- "userId" --> Auth + OrderCtx -- "userId" --> Auth + Catalog -- "brandId → Product" --> Catalog + Inventory -- "productId" --> Catalog + LikeCtx -- "productId" --> Catalog + OrderCtx -- "productId + 스냅샷" --> Catalog + OrderCtx -- "재고 차감" --> Inventory + LikeCtx -. "likeCount 갱신" .-> Catalog +``` + +### 통신 방식 (현재 모놀리스) + +| 호출자 | 피호출자 | 방식 | 예시 | 분리 시 전환 | +|--------|---------|------|------|-------------| +| `ProductFacade` | `BrandService` | 직접 호출 | 브랜드 존재 확인 후 상품 생성 | 같은 BC — 분리 불필요 | +| `ProductFacade` | `StockService` | 직접 호출 | 상품 + 재고 동시 생성 | API 호출 또는 이벤트 | +| `LikeFacade` | `ProductService` | 직접 호출 | 삭제된 상품 체크 + likeCount 갱신 | **도메인 이벤트** | +| `OrderFacade` | `ProductService` + `StockService` | 직접 호출 | 상품 확인 → 재고 차감 → 주문 생성 | Saga 패턴 | + +### 설계적 주의 지점: `product.incrementLikeCount()` + +```java +// LikeFacade — 좋아요 컨텍스트가 카탈로그 컨텍스트를 직접 수정 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); // 카탈로그에서 검증 + // ... 좋아요 로직 + product.incrementLikeCount(); // ← 좋아요 BC가 카탈로그 BC의 엔티티를 직접 변경 +} +``` + +모놀리스에서는 실용적이지만, 물리적 분리 시 **도메인 이벤트**로 전환해야 한다: + +```java +// 분리 시: 좋아요 → 이벤트 발행 → 카탈로그가 수신하여 likeCount 갱신 +// 현재 모놀리스에서는 Facade에서 직접 호출하는 것이 실용적 +``` + +--- + +## 6. 도메인 서비스 vs 애플리케이션 서비스 판별 기준 + +### 판별 흐름 + +```mermaid +flowchart TD + A["로직이 있다"] --> B{"특정 엔티티 하나의 책임인가?"} + B -- "Yes" --> C["엔티티 메서드
예: product.incrementLikeCount()"] + B -- "No" --> D{"비즈니스 의사결정을 내리는가?"} + D -- "Yes" --> E["도메인 서비스
domain/"] + D -- "No (조율/절차)" --> F{"여러 도메인을 조합하는가?"} + F -- "Yes" --> G["Facade
application/XxxFacade"] + F -- "No (단일 도메인 CRUD)" --> H["Service
application/XxxService"] + + style C fill:#e8f5e9 + style E fill:#e3f2fd + style G fill:#fff3e0 + style H fill:#fff3e0 +``` + +### 코드로 보는 구분 + +**애플리케이션 서비스(Service) — 단일 도메인 CRUD 조율:** + +```java +// application/brand/BrandService: Repository를 호출하여 CRUD 수행 +// 중복 이름 체크는 비즈니스 규칙이지만, 엔티티/VO가 판단을 내리고 Service는 조율함 +public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); // VO가 이름 유효성 검증 + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← 조율: DB 상태 확인 후 거부 + }); + return brandRepository.save(new BrandModel(brandName, description)); +} +``` + +**애플리케이션 서비스(Facade) — 다중 도메인 조합:** + +```java +// application/product/ProductFacade: 여러 Service를 조합하여 유스케이스 실행 +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) + ProductModel product = productService.register(...); // 2. 상품 생성 (위임) + stockService.create(product.getId(), initialStock); // 3. 재고 생성 (위임) + return product; // ← 자체 규칙 없음, 절차만 있음 +} +``` + +### 판별 기준 요약표 + +| 질문 | 도메인 서비스 | 애플리케이션 서비스 | +|------|-------------|-------------------| +| 비즈니스 의사결정을 직접 내리는가? | **내린다** (여러 엔티티 상태 종합 판단) | **안 내린다** (엔티티/VO에 위임) | +| 다른 Service를 조합하는가? | 같은 도메인 내 객체만 | **여러 도메인 Service를 조합** | +| `@Transactional` 경계인가? | 아닐 수 있음 | **맞다** (유스케이스 단위) | +| 제거하면 비즈니스 규칙이 깨지는가? | **깨진다** | 절차가 사라질 뿐, 규칙은 유지됨 | + +### 현재 프로젝트의 배치 + +| 컴포넌트 | 계층 | 역할 | +|---------|------|------| +| `ExampleService` | application | 예시 조회 (ExampleFacade 통합) | +| `BrandService` | application | 브랜드명 유니크 검증, CRUD | +| `ProductService` | application | 상품 CRUD | +| `StockService` | application | 재고 생성, 차감 | +| `LikeToggleService` | **domain** | 좋아요 멱등성 판단 (Like + Product 종합 의사결정) | +| `LikeService` | application | 좋아요 저장/조회 | +| `OrderService` | application | 주문/주문상품 CRUD, 소유권 검증(엔티티 위임) | +| `MemberSignupService` | application | 회원가입 (중복 ID 체크 + 생성) | +| `MemberAuthService` | application | 인증 (비밀번호 검증은 엔티티 위임) | +| `MemberPasswordService` | application | 비밀번호 변경 (검증은 엔티티/VO 위임) | +| `BrandFacade` | application | 삭제 시 소속 상품 연쇄 soft delete | +| `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| `LikeFacade` | application | 데이터 조회 → LikeToggleService에 판단 위임 → 결과 저장 | +| `OrderFacade` | application | 재고 차감 + 스냅샷 + 주문 생성, 주문 조회 | +| `MemberFacade` | application | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | + +--- + +## 부록: 어그리게이트 분리 판단 — Product vs Stock + +`ProductModel`과 `StockModel`은 1:1이지만 **별도 어그리게이트**다. + +**근거:** 상품 정보를 수정할 때 재고를 함께 잠글 필요가 없고, 재고를 변경할 때 상품 정보를 함께 잠글 필요가 없다. 독립적으로 변경 가능한 단위이므로 별도 어그리게이트가 맞다. + +| 변경 시나리오 | Product 변경? | Stock 변경? | 결론 | +|-------------|:----------:|:----------:|------| +| 상품명 수정 | O | X | 독립 | +| 가격 수정 | O | X | 독립 | +| 재고 차감 (주문) | X | O | 독립 | +| 상품 등록 (초기 재고 포함) | O | O | Facade에서 조율 | diff --git a/docs/design/mermaid/01-requirements.md b/docs/design/mermaid/01-requirements.md new file mode 100644 index 000000000..0a7cd3bae --- /dev/null +++ b/docs/design/mermaid/01-requirements.md @@ -0,0 +1,335 @@ +# 이커머스 요구사항 정의서 + +--- + +## 1. Brand (브랜드) + +### 유저 스토리 + +- 고객은 브랜드 정보를 조회할 수 있다. +- 어드민은 새로운 브랜드를 등록할 수 있다. +- 어드민은 브랜드 정보(이름, 설명)를 수정할 수 있다. +- 어드민은 브랜드를 삭제할 수 있다. + +### 기능 흐름 + +**브랜드 등록** + +1. 어드민 인증 확인 (@AdminUser) +2. 브랜드명 유효성 검증 (빈값 불가) +3. 브랜드명 중복 체크 (Q17: 중복 불가 → 409 Conflict) +4. 브랜드 생성 및 저장 +5. 등록 결과 반환 + +**브랜드 수정** + +1. 어드민 인증 확인 +2. 브랜드 존재 여부 확인 (없으면 404) +3. 브랜드명 변경 시 중복 체크 +4. name, description 수정 및 저장 +5. 소속 상품에는 별도 업데이트 불필요 (Q31: brandId 참조 방식으로 자동 반영) + +**브랜드 삭제 (연쇄)** + +1. 어드민 인증 확인 +2. 브랜드 존재 여부 확인 (없으면 404) +3. 해당 브랜드 소속 상품 전체 soft delete (Q1: Soft Delete 연쇄) +4. 브랜드 soft delete +5. 트랜잭션 커밋 (전체 하나의 트랜잭션) + +### 비즈니스 규칙 + +- 브랜드명은 유니크해야 한다 (Q17) +- 브랜드 삭제 시 소속 상품도 함께 soft delete (Q1) +- 브랜드-상품은 ID 참조만 사용, JPA 연관관계 없음 (ADR-008) +- 브랜드 restore는 설계 범위에서 제외 (Q2) +- 삭제된 브랜드에 새 상품 등록 불가 (Q27) +- 브랜드명 수정 시 소속 상품에 자동 반영됨 — brandId 참조 방식 (Q31) +- 브랜드 필드: name(필수, BrandName VO), description(선택) (Q14) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| GET | `/api/v1/brands/{brandId}` | 고객 브랜드 정보 조회 | 불필요 | +| POST | `/api-admin/v1/brands` | 브랜드 등록 | @AdminUser | +| GET | `/api-admin/v1/brands?page=0&size=20` | 어드민 브랜드 목록 (삭제 포함) | @AdminUser | +| GET | `/api-admin/v1/brands/{brandId}` | 어드민 브랜드 상세 | @AdminUser | +| PUT | `/api-admin/v1/brands/{brandId}` | 브랜드 수정 | @AdminUser | +| DELETE | `/api-admin/v1/brands/{brandId}` | 브랜드 삭제 | @AdminUser | + +--- + +## 2. Product + Stock (상품 + 재고) + +### 유저 스토리 + +- 고객은 상품 목록을 조회할 수 있다. +- 고객은 상품 상세 정보를 볼 수 있다. +- 어드민은 새로운 상품을 등록할 수 있다. +- 어드민은 상품 정보를 수정할 수 있다. +- 어드민은 상품을 삭제할 수 있다. + +### 기능 흐름 + +**상품 등록** + +1. 어드민 인증 확인 (@AdminUser) +2. 브랜드 존재 여부 확인 (삭제된 브랜드 불가, Q27) +3. 상품 정보 검증 (name 필수, price >= 0) +4. 상품 생성 및 저장 +5. Stock 생성 (initialStock 수량, Product와 1:1, Q4) +6. 하나의 트랜잭션으로 처리 + +**상품 목록 조회 (Customer)** + +1. 정렬 파라미터 파싱 (Q24: created_desc / price_asc / price_desc / likes_desc) +2. 페이지네이션 적용 (Q15: Spring Pageable) +3. 삭제된 상품 제외 (deletedAt IS NULL) +4. 브랜드 필터 적용 (선택) +5. Stock 정보 조합 +6. 재고 상태 변환 (Q6: >10 → IN_STOCK, 1~10 → LOW_STOCK, 0 → OUT_OF_STOCK) + +**상품 목록 조회 (Admin)** + +1. 어드민 인증 확인 +2. 삭제된 상품도 포함하여 조회 가능 +3. 정확한 재고 수량 표시 (Q6) +4. createdAt, updatedAt, deletedAt 포함 + +**상품 수정 (Admin)** + +1. 어드민 인증 확인 +2. 상품 존재 여부 확인 +3. 수정 가능 필드만 변경: name, description, price (Q29) +4. 수정 불가 필드: brandId(브랜드 이동 불가), likeCount(시스템 관리) (Q29) +5. 저장 + +### 비즈니스 규칙 + +- 상품 필드: name, description, price(Money VO), brandId 필수 (Q3) +- 재고는 별도 Stock 엔티티로 분리 — Product와 1:1 (Q4) +- 가격은 Money VO (int 내부 타입, 원화, 음수 불가, 0원 허용) (Q5) +- 고객에게는 재고 상태(IN_STOCK/LOW_STOCK/OUT_OF_STOCK)만 표시 (Q6) +- 어드민에게는 정확한 재고 수량 표시 (Q6) +- 상품명 중복 허용 (Q18) +- 수정 가능: name, description, price / 수정 불가: brandId, likeCount (Q29) +- 삭제된 브랜드에 상품 등록 불가 (Q27) + +### 정렬 옵션 (Q24) + +| 정렬 값 | 동작 | +|---------|------| +| `created_desc` (기본) | 상품 등록일 최신순 | +| `price_asc` | 가격 낮은순 | +| `price_desc` | 가격 높은순 | +| `likes_desc` | 좋아요 많은순 | + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| GET | `/api/v1/products?brandId={brandId}&sort={sort}&page=0&size=20` | 고객 상품 목록 | 불필요 | +| GET | `/api/v1/products/{productId}` | 고객 상품 상세 | 불필요 | +| POST | `/api-admin/v1/products` | 상품 등록 (initialStock 포함) | @AdminUser | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | 어드민 상품 목록 (삭제 포함) | @AdminUser | +| GET | `/api-admin/v1/products/{productId}` | 어드민 상품 상세 | @AdminUser | +| PUT | `/api-admin/v1/products/{productId}` | 상품 수정 | @AdminUser | +| DELETE | `/api-admin/v1/products/{productId}` | 상품 삭제 | @AdminUser | + +--- + +## 3. Like (좋아요) + +### 유저 스토리 + +- 사용자는 상품을 찜할 수 있다. +- 이미 찜한 상품을 다시 누르면 무시된다 (멱등성). +- 사용자는 찜을 취소할 수 있다. +- 이미 취소한 찜을 다시 취소하면 무시된다 (멱등성). +- 사용자는 찜한 상품 목록을 볼 수 있다. + +### 기능 흐름 + +**좋아요 추가** + +1. 로그인 사용자만 가능 (@LoginMember) +2. 상품 존재 여부 확인 (삭제된 상품 불가, Q16) +3. 기존 좋아요 존재 여부 판단 +4. 없으면 좋아요 저장 + likeCount 증가 +5. 있으면 아무 동작 없이 200 OK 반환 (Q7: 멱등성) + +**좋아요 취소** + +1. 로그인 사용자만 가능 +2. 기존 좋아요 존재 여부 판단 +3. 있으면 삭제 + likeCount 감소 (음수 방지, Q28) +4. 없으면 아무 동작 없이 200 OK 반환 (Q7: 멱등성) + +**좋아요 목록 조회** + +1. 로그인 사용자만 가능 +2. 본인의 좋아요 목록만 조회 +3. 삭제된 상품은 목록에서 제외 (Q16) +4. 좋아요 누른 시간 최신순 정렬 (Q23) +5. 페이지네이션 적용 + +### 비즈니스 규칙 + +- 좋아요 등록/취소 모두 멱등성 보장, 200 OK 반환 (Q7) +- Product 테이블에 likeCount 비정규화 유지 (Q8) +- likeCount는 0 미만이 될 수 없다 — 애플리케이션 가드 + DB CHECK (Q28) +- 삭제된 상품에는 좋아요 불가 (Q16) +- 삭제된 상품은 좋아요 목록에서 제외 (Q16) +- 좋아요 목록은 좋아요 누른 시간 최신순 (Q23) +- 좋아요 데이터 자체는 삭제하지 않음 (상품 복구 시 다시 보임) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| POST | `/api/v1/products/{productId}/likes` | 좋아요 추가 | @LoginMember | +| DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | @LoginMember | +| GET | `/api/v1/users/{userId}/likes` | 내 좋아요 목록 | @LoginMember | + +--- + +## 4. Order (주문) + +### 유저 스토리 + +- 고객은 여러 상품을 한 번에 주문할 수 있다. +- 주문하면 재고가 즉시 차감된다. +- 주문 정보에는 당시의 상품 정보가 스냅샷으로 저장된다. +- 고객은 자신의 주문 내역을 조회할 수 있다. +- 고객은 주문 상세(상품별 수량, 금액)를 볼 수 있다. +- 어드민은 전체 주문을 조회할 수 있다. + +### 기능 흐름 + +**주문 생성** + +1. 로그인 사용자만 가능 (@LoginMember) +2. 각 주문 상품에 대해: + a. 상품 존재 여부 확인 — 삭제된 상품 포함 시 전체 실패 (Q20) + b. 수량 검증 — 1 이상 (Q26) + c. 재고 확인 및 차감 — 부족 시 전체 실패 (Q19) +3. Order 생성 (status = CREATED, Q11) +4. OrderItem 생성 — 상품명, 가격 스냅샷 저장 (Q9) +5. 총액은 서버에서 계산하여 Order에 저장 (Q12) +6. 전체 하나의 트랜잭션 — 실패 시 전체 롤백 (Q19: All or Nothing) + +**주문 목록 조회** + +1. 고객: 본인 주문만 조회 (Q25: userId 필터) +2. 날짜 범위 필터 적용 (startAt, endAt 쿼리 파라미터) +3. 어드민: 전체 주문 조회 가능 (Q25) +4. 최신순 정렬, 페이지네이션 적용 + +**주문 상세 조회** + +1. 고객: 본인 주문만 상세 조회 가능 +2. OrderItem 목록 포함 (스냅샷된 상품명, 가격, 수량, 소계) +3. 상품이 삭제된 후에도 스냅샷으로 조회 가능 + +### 비즈니스 규칙 + +- Order + OrderItem 분리 (Q10: 1:N 관계) +- OrderItem에 주문 시점의 상품명, 가격, 수량 스냅샷 저장 (Q9) +- OrderStatus: CREATED만 사용, 나머지(CONFIRMED, SHIPPING, DELIVERED, CANCELLED)는 미래 확장용 (Q11) +- 총액은 서버에서 계산, 클라이언트 전송값 무시 (Q12) +- 재고 부족 시 주문 전체 실패 — All or Nothing (Q19) +- 삭제된 상품 포함 시 전체 실패 + 상세 에러 메시지 (Q20) +- 주문 생성과 동시에 재고 차감, 하나의 트랜잭션 (Q21) +- 자기 자신 주문 제한 없음 (Q22: 어드민/고객 인증 체계 분리) +- 고객은 본인 주문만, 어드민은 전체 조회 가능 (Q25) +- 수량은 최소 1, 최대 해당 상품 재고 이하 (Q26) + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| POST | `/api/v1/orders` | 주문 생성 | @LoginMember | +| GET | `/api/v1/orders?startAt={date}&endAt={date}` | 내 주문 목록 (날짜 범위 필터) | @LoginMember | +| GET | `/api/v1/orders/{orderId}` | 주문 상세 | @LoginMember | +| GET | `/api-admin/v1/orders?page=0&size=20` | 전체 주문 목록 | @AdminUser | +| GET | `/api-admin/v1/orders/{orderId}` | 주문 상세 | @AdminUser | + +--- + +## 5. 공통 + +### 인증 (Q13: ArgumentResolver) + +- `@LoginMember` — 고객 인증 (X-Loopers-LoginId + X-Loopers-LoginPw → MemberModel 주입) +- `@AdminUser` — 어드민 인증 (X-Loopers-Ldap → AdminInfo 주입) + +### 페이지네이션 (Q15) + +- Spring Pageable 사용 +- 공통 파라미터: page, size +- JPA Repository와 자연스럽게 연동 + +### 에러 처리 (Q32) + +기존 ErrorType 4종(NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR)을 활용하고, 메시지로 상황을 구분한다. + +| 상황 | ErrorType | 메시지 예시 | +|------|-----------|------------| +| 존재하지 않는 상품/브랜드/주문 | NOT_FOUND | "상품을 찾을 수 없습니다" | +| 브랜드명 중복 | CONFLICT | "이미 존재하는 브랜드명입니다" | +| 재고 부족 | BAD_REQUEST | "재고가 부족합니다: [상품명]" | +| 주문 수량 0 이하 | BAD_REQUEST | "주문 수량은 1 이상이어야 합니다" | +| 삭제된 상품에 좋아요 | NOT_FOUND | "상품을 찾을 수 없습니다" | +| 삭제된 상품이 주문에 포함 | NOT_FOUND | "삭제된 상품이 포함되어 있습니다: [상품명]" | + +--- + +## 6. API 엔드포인트 요약 + +| 구분 | Customer | Admin | 합계 | +|------|----------|-------|------| +| Brand | 1 | 5 | 6 | +| Product | 2 | 5 | 7 | +| Like | 3 | 0 | 3 | +| Order | 3 | 2 | 5 | +| **합계** | **9** | **12** | **21** | + +--- + +## 7. Q&A 트레이드오프 추적표 + +| Q# | 결정 사항 | 반영 위치 | +|----|-----------|-----------| +| Q1 | Soft Delete 연쇄 | Brand 비즈니스 규칙, 기능 흐름 | +| Q2 | 브랜드 restore 제외 | Brand 비즈니스 규칙 | +| Q3 | 상품 필드 (name, desc, price, brandId) | Product 비즈니스 규칙 | +| Q4 | Stock 별도 엔티티 분리 | Product 비즈니스 규칙, 기능 흐름 | +| Q5 | Money VO (int) | Product 비즈니스 규칙 | +| Q6 | 고객/어드민 재고 표시 차이 | Product 기능 흐름 | +| Q7 | 좋아요 멱등성 | Like 기능 흐름, 비즈니스 규칙 | +| Q8 | likeCount 비정규화 | Like 비즈니스 규칙 | +| Q9 | 스냅샷 (상품명 + 가격 + 수량) | Order 비즈니스 규칙 | +| Q10 | Order + OrderItem 분리 | Order 비즈니스 규칙 | +| Q11 | OrderStatus enum 미래 확장 | Order 비즈니스 규칙 | +| Q12 | 서버 계산 totalAmount | Order 기능 흐름, 비즈니스 규칙 | +| Q13 | ArgumentResolver | 공통 인증 | +| Q14 | 브랜드 필드 (name + description) | Brand 비즈니스 규칙 | +| Q15 | Spring Pageable | 공통 페이지네이션 | +| Q16 | 삭제된 상품 좋아요 불가 + 목록 제외 | Like 기능 흐름, 비즈니스 규칙 | +| Q17 | 브랜드명 중복 불가 | Brand 기능 흐름, 비즈니스 규칙 | +| Q18 | 상품명 중복 허용 | Product 비즈니스 규칙 | +| Q19 | 재고 부족 전체 실패 | Order 기능 흐름, 비즈니스 규칙 | +| Q20 | 삭제 상품 포함 시 전체 실패 | Order 기능 흐름, 비즈니스 규칙 | +| Q21 | 주문 생성 시 재고 즉시 차감 | Order 기능 흐름, 비즈니스 규칙 | +| Q22 | 자기 자신 주문 제한 없음 | Order 비즈니스 규칙 | +| Q23 | 좋아요 목록 최신순 | Like 기능 흐름 | +| Q24 | 커스텀 sort 4종 | Product 정렬 옵션 | +| Q25 | 주문 조회 범위 (고객: 본인, 어드민: 전체) | Order 기능 흐름, 비즈니스 규칙 | +| Q26 | 수량 최소 1, 최대 재고 이하 | Order 비즈니스 규칙 | +| Q27 | 삭제된 브랜드에 상품 등록 불가 | Brand/Product 비즈니스 규칙 | +| Q28 | likeCount 음수 방지 | Like 비즈니스 규칙 | +| Q29 | 상품 수정 범위 (name, desc, price만) | Product 기능 흐름, 비즈니스 규칙 | +| Q30 | ~~재고 절대값 세팅~~ (구현 범위 제외) | - | +| Q31 | 브랜드명 변경 자동 반영 | Brand 기능 흐름, 비즈니스 규칙 | +| Q32 | 기존 ErrorType 4종 활용 | 공통 에러 처리 | diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md new file mode 100644 index 000000000..b8ef8bd86 --- /dev/null +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -0,0 +1,122 @@ +# 유비쿼터스 언어 + +프로젝트 전반에서 통일하여 사용하는 도메인 용어를 정의합니다. +코드, 문서, 커뮤니케이션에서 동일한 의미로 사용합니다. + +--- + +## 1. Actor (행위자) + +| 용어 | 설명 | 인증 방식 | +|------|------|-----------| +| **Customer** | 로그인한 일반 사용자. 상품 조회, 좋아요, 주문 가능 | `@LoginMember` (X-Loopers-LoginId + X-Loopers-LoginPw) | +| **Admin** | 관리자. 브랜드/상품/주문 관리 | `@AdminUser` (X-Loopers-Ldap) | + +--- + +## 2. 카탈로그 BC (Brand + Product + Stock) + +> 비즈니스 관심사: "판매할 상품 카탈로그를 관리한다" +> 브랜드 삭제 → 소속 상품 연쇄 soft delete가 하나의 트랜잭션으로 처리되므로 같은 BC에 속한다. + +### Brand (어그리게이트) + +| 용어 | 타입 | 설명 | +|------|------|------| +| **BrandModel** | Entity | 브랜드 엔티티. BaseEntity 상속. name(BrandName VO) + description | +| **BrandName** | @Embeddable VO | 브랜드명 값 객체. 유니크 제약, 빈값 불가, `value()` 접근자 | +| **BrandService** | Domain Service | 단일 도메인 로직. CRUD, 브랜드명 유니크 검증 | + +### Product (어그리게이트) + +| 용어 | 타입 | 설명 | +|------|------|------| +| **ProductModel** | Entity | 상품 엔티티. name, description, price(Money), brandId(ID 참조), likeCount(비정규화) | +| **Money** | @Embeddable VO | 금액 값 객체. int 내부 타입(원화), 음수 불가, 0원 허용. `add()`, `multiply()` 행위 메서드 | +| **StockModel** | Entity | 재고 엔티티. Product와 1:1 관계. `decrease()`, `increase()`, `hasEnough()` 행위 메서드 | +| **StockStatus** | 표시 상태 | 고객에게 보여주는 재고 상태. IN_STOCK(>10), LOW_STOCK(1~10), OUT_OF_STOCK(0) | +| **ProductService** | Domain Service | 상품 CRUD, likeCount 증감, soft delete | +| **StockService** | Domain Service | 재고 생성, 조회, 차감(`checkAndDecrease`) | +| **initialStock** | 요청 파라미터 | 상품 등록 시 초기 재고 수량 | + +### Application Layer + +| 용어 | 타입 | 설명 | +|------|------|------| +| **BrandFacade** | Application Facade | 유스케이스 조합. 삭제 시 소속 상품 연쇄 soft delete | +| **ProductFacade** | Application Facade | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| **브랜드 삭제 연쇄** | 비즈니스 규칙 | 브랜드 삭제 → 소속 상품 전체 soft delete. 하나의 트랜잭션 (Q1) | + +--- + +## 3. 좋아요 BC (Like) + +> 비즈니스 관심사: "고객의 상품 선호를 추적한다" + +| 용어 | 타입 | 설명 | +|------|------|------| +| **LikeModel** | Entity | 좋아요 엔티티. userId + productId 유니크 제약 | +| **멱등성 (Idempotency)** | 비즈니스 규칙 | 좋아요 중복 등록 → 무시 + 200 OK. 취소 중복 → 무시 + 200 OK (Q7) | +| **likeCount 동기화** | 비즈니스 규칙 | 좋아요 추가 → `incrementLikeCount()`, 취소 → `decrementLikeCount()`. 음수 방지 가드 포함 (Q28) | +| **LikeService** | Domain Service | 좋아요 등록/취소, 존재 여부 조회, 목록 조회 | +| **LikeFacade** | Application Facade | 삭제된 상품 체크, 트랜잭션 내 likeCount 동기화 | + +--- + +## 4. 주문 BC (Order) + +> 비즈니스 관심사: "주문 이력을 기록하고 관리한다" + +| 용어 | 타입 | 설명 | +|------|------|------| +| **OrderModel** | Entity | 주문 엔티티. userId, status(OrderStatus), totalAmount(Money) | +| **OrderItemModel** | Entity | 주문 상세 엔티티. orderId, productId, 스냅샷(productName, productPrice), quantity. `subtotal()` 행위 메서드 | +| **OrderStatus** | Enum | 주문 상태. CREATED(현재 사용) → CONFIRMED → SHIPPING → DELIVERED → CANCELLED (미래 확장용) | +| **스냅샷 (Snapshot)** | 비즈니스 개념 | 주문 시점의 상품명, 가격을 OrderItem에 복사 저장. 상품 삭제/변경 후에도 주문 내역 조회 가능 (Q9) | +| **All or Nothing** | 비즈니스 규칙 | 재고 부족 또는 삭제된 상품 포함 시 주문 전체 실패. 부분 성공 없음 (Q19) | +| **OrderService** | Domain Service | 주문 생성(총액 계산 포함), 조회 | +| **OrderFacade** | Application Facade | 상품 조회 → 재고 차감 → 주문 생성. 하나의 트랜잭션 | + +--- + +## 5. 공통 패턴 + +### 6.1 엔티티 기반 + +| 용어 | 설명 | +|------|------| +| **BaseEntity** | 모든 엔티티의 부모 클래스. id, createdAt, updatedAt, deletedAt 자동 관리 | +| **Soft Delete** | `deletedAt`을 세팅하여 논리 삭제. `delete()` / `restore()` 메서드. 조회 시 `findByIdAndDeletedAtIsNull` | +| **guard()** | BaseEntity의 검증 메서드. `@PrePersist` / `@PreUpdate` 시 호출 | + +### 6.2 값 객체 (Value Object) + +| 용어 | 설명 | +|------|------| +| **@Embeddable VO** | JPA 임베딩 가능한 값 객체 패턴. 생성 시 검증, `value()` 접근자, equals/hashCode | +| **value()** | VO의 내부 값 접근 메서드. getter 대신 사용 (`loginId.value()`, `price.value()`) | + +### 6.3 아키텍처 레이어 + +| 용어 | 설명 | +|------|------| +| **Controller** | HTTP 요청/응답 처리, 인증 어노테이션 적용. `interfaces/api/` 패키지 | +| **Facade** | 유스케이스 조합, `@Transactional` 경계 관리, 여러 Service 조합. `application/` 패키지 | +| **Service** | 단일 도메인 비즈니스 로직. `domain/` 패키지 | +| **Repository** | 데이터 접근 인터페이스(domain) + JPA 구현체(infrastructure) | + +### 6.4 에러 처리 + +| 용어 | 설명 | +|------|------| +| **CoreException** | 비즈니스 예외. ErrorType enum 기반으로 생성 | +| **ErrorType** | 에러 유형 enum. NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR (Q32) | +| **ApiResponse** | 공통 응답 래퍼. `meta`(result, errorCode, message) + `data`(응답 본문) | + +### 6.5 관계 설계 + +| 용어 | 설명 | +|------|------| +| **ID 참조** | JPA 연관관계(@ManyToOne 등) 없이 `Long brandId`, `Long productId`로만 참조 (ADR-008) | +| **1:1 분리** | Product-Stock 관계. 변경 이유가 다르므로 별도 엔티티/테이블 (ADR-001) | +| **비정규화** | Product.likeCount. 조회 성능을 위해 집계값을 저장. 쓰기 시 동기화 필요 (ADR-002) | diff --git a/docs/design/mermaid/03-sequence-brand-delete.mmd b/docs/design/mermaid/03-sequence-brand-delete.mmd new file mode 100644 index 000000000..4510846c6 --- /dev/null +++ b/docs/design/mermaid/03-sequence-brand-delete.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant A as Admin + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + + A->>F: 브랜드 삭제 요청 (DELETE /api-admin/v1/brands/{brandId}) + Note over F: @AdminUser 인증 + + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>BS: 브랜드 조회 + alt 브랜드 없음 + BS-->>F: NOT_FOUND + end + F->>PS: 소속 상품 전체 조회 + loop 각 소속 상품 + F->>PS: 상품 soft delete + end + F->>BS: 브랜드 soft delete + end + + F-->>A: 200 OK \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-like-toggle.mmd b/docs/design/mermaid/03-sequence-like-toggle.mmd new file mode 100644 index 000000000..77f372b12 --- /dev/null +++ b/docs/design/mermaid/03-sequence-like-toggle.mmd @@ -0,0 +1,47 @@ +sequenceDiagram + participant C as Customer + participant F as LikeFacade + participant PS as ProductService + participant LS as LikeService + + rect rgb(232, 245, 233) + Note over C,LS: 좋아요 추가 + C->>F: 좋아요 요청 (POST /api/v1/products/{productId}/likes) + Note over F: @LoginMember 인증 + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>PS: 상품 존재 확인 + alt 삭제된 상품 + PS-->>F: NOT_FOUND + end + F->>LS: 좋아요 존재 여부 확인 + alt 이미 좋아요 존재 + Note over F: 무시 (멱등성) + else 좋아요 없음 + F->>LS: 좋아요 저장 + F->>PS: likeCount 증가 + end + end + deactivate F + F-->>C: 200 OK (liked: true) + end + + rect rgb(255, 235, 238) + Note over C,LS: 좋아요 취소 + C->>F: 취소 요청 (DELETE /api/v1/products/{productId}/likes) + Note over F: @LoginMember 인증 + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>LS: 좋아요 존재 여부 확인 + alt 좋아요 존재 + F->>LS: 좋아요 삭제 + F->>PS: likeCount 감소 (음수 방지) + else 좋아요 없음 + Note over F: 무시 (멱등성) + end + end + deactivate F + F-->>C: 200 OK (liked: false) + end \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-order-creation.mmd b/docs/design/mermaid/03-sequence-order-creation.mmd new file mode 100644 index 000000000..39836c193 --- /dev/null +++ b/docs/design/mermaid/03-sequence-order-creation.mmd @@ -0,0 +1,31 @@ +sequenceDiagram + participant C as Customer + participant F as OrderFacade + participant PS as ProductService + participant SS as StockService + participant OS as OrderService + + C->>F: 주문 요청 (POST /api/v1/orders) + Note over F: @LoginMember 인증 + + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + loop 각 주문 상품 + F->>PS: 상품 조회 + alt 삭제된 상품 + PS-->>F: NOT_FOUND + end + F->>SS: 재고 확인 및 차감 + alt 재고 부족 + SS-->>F: BAD_REQUEST + Note over F: 전체 롤백 (All or Nothing) + end + end + F->>OS: 주문 생성 (총액 서버 계산) + Note over OS: Order + OrderItem 저장 + Note over OS: 스냅샷: 상품명, 가격, 수량 + end + deactivate F + + F-->>C: 200 OK (주문 정보) \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-product-list.mmd b/docs/design/mermaid/03-sequence-product-list.mmd new file mode 100644 index 000000000..cb907a35d --- /dev/null +++ b/docs/design/mermaid/03-sequence-product-list.mmd @@ -0,0 +1,28 @@ +sequenceDiagram + participant C as Customer + participant A as Admin + participant F as ProductFacade + participant PS as ProductService + participant SS as StockService + + rect rgb(232, 245, 233) + Note over C,SS: Customer 상품 목록 조회 + C->>F: GET /api/v1/products?sort=likes_desc&page=0 + F->>PS: 상품 목록 조회 (삭제 제외, 정렬, 페이지네이션) + F->>SS: 재고 정보 조합 + Note over F: 재고 상태 변환 + Note over F: >10 → IN_STOCK + Note over F: 1~10 → LOW_STOCK + Note over F: 0 → OUT_OF_STOCK + F-->>C: 200 OK (상품 목록 + 재고 상태) + end + + rect rgb(227, 242, 253) + Note over A,SS: Admin 상품 목록 조회 + A->>F: GET /api-admin/v1/products?page=0 + Note over F: @AdminUser 인증 + F->>PS: 상품 목록 조회 (삭제 포함 가능) + F->>SS: 재고 정보 조합 + Note over F: 정확한 재고 수량 표시 + F-->>A: 200 OK (상품 목록 + 재고 수량) + end \ No newline at end of file diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd new file mode 100644 index 000000000..1857a280c --- /dev/null +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -0,0 +1,107 @@ +classDiagram + direction LR + + namespace Catalog { + class BrandName { + <> + -String value + } + class BrandModel { + -BrandName name + -String description + } + class BrandFacade { + 삭제 시 상품 연쇄 soft delete + } + class Money { + <> + -int value + +add(Money) Money + +multiply(int) Money + } + class ProductModel { + -String name + -String description + -Money price + -Long brandId + -int likeCount + +incrementLikeCount() + +decrementLikeCount() + } + class StockModel { + -Long productId + -int quantity + +hasEnough(int) boolean + +decrease(int) + +increase(int) + } + class ProductFacade { + 상품 + Stock 동시 생성 + } + } + + namespace Like { + class LikeModel { + -Long userId + -Long productId + } + class LikeToggleService { + <> + +like(existing, product, userId, productId) Optional~LikeModel~ + +unlike(like, product) + } + class LikeFacade { + 데이터 조회 → 판단 위임 → 결과 저장 + } + } + + namespace Order { + class OrderStatus { + <> + CREATED + CONFIRMED + SHIPPING + DELIVERED + CANCELLED + } + class OrderModel { + -Long userId + -OrderStatus status + -Money totalAmount + } + class OrderItemModel { + -Long orderId + -Long productId + -String productName + -Money productPrice + -int quantity + +subtotal() Money + } + class OrderFacade { + 재고 차감 + 스냅샷 + 주문 생성 + } + } + + BrandModel *-- BrandName + ProductModel *-- Money + OrderModel *-- OrderStatus + OrderModel *-- Money + OrderItemModel *-- Money + + BrandFacade --> BrandService + BrandService --> BrandModel + ProductFacade --> ProductService + ProductFacade --> StockService + ProductService --> ProductModel + StockService --> StockModel + LikeFacade --> LikeService + LikeFacade --> ProductService + LikeFacade --> LikeToggleService + LikeToggleService --> LikeModel + LikeToggleService --> ProductModel + LikeService --> LikeModel + OrderFacade --> OrderService + OrderFacade --> ProductService + OrderFacade --> StockService + OrderService --> OrderModel + OrderService --> OrderItemModel \ No newline at end of file diff --git a/docs/design/mermaid/05-erd.mmd b/docs/design/mermaid/05-erd.mmd new file mode 100644 index 000000000..8baf2ac4d --- /dev/null +++ b/docs/design/mermaid/05-erd.mmd @@ -0,0 +1,59 @@ +erDiagram + member ||--o{ member_like : "has" + member ||--o{ orders : "places" + brand ||--o{ product : "has" + product ||--|| stock : "has" + product ||--o{ member_like : "receives" + product ||--o{ order_item : "ordered_as" + orders ||--|{ order_item : "contains" + + member { + bigint id PK + varchar login_id UK "LoginId VO" + varchar password "BCrypt encoded" + varchar name "MemberName VO" + varchar email "Email VO, nullable" + date birth_date + timestamp deleted_at "soft delete" + } + brand { + bigint id PK + varchar name UK "BrandName VO" + varchar description + timestamp deleted_at "soft delete" + } + product { + bigint id PK + varchar name + text description + int price "Money VO" + bigint brand_id "refs brand" + int like_count "비정규화" + timestamp deleted_at "soft delete" + } + stock { + bigint id PK + bigint product_id UK "1:1" + int quantity + } + member_like { + bigint id PK + bigint user_id "refs member" + bigint product_id "refs product" + timestamp created_at "좋아요 시간, 정렬 기준(Q23)" + } + orders { + bigint id PK + bigint user_id "refs member" + varchar status "OrderStatus" + int total_amount "Money VO" + timestamp created_at "주문일시, 날짜필터 기준" + } + order_item { + bigint id PK + bigint order_id "refs orders" + bigint product_id "refs product" + varchar product_name "snapshot" + int product_price "snapshot" + int quantity + } \ No newline at end of file diff --git a/docs/design/requirements/api-spec.md b/docs/design/requirements/api-spec.md new file mode 100644 index 000000000..1f2f5cbb7 --- /dev/null +++ b/docs/design/requirements/api-spec.md @@ -0,0 +1,154 @@ +## 🎯 배경 + +**좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 이커머스**. + +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결돼요. + +우린 이 흐름을 하나씩 직접 만들어갈 거예요. + +--- + +## 🧭 서비스 흐름 예시 + +1. 사용자가 **회원가입**을 하고 +2. 여러 브랜드의 상품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. +3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. +4. 유저의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. + +--- + +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +--- + +### 📡 나아가며 + +> ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** +> \ No newline at end of file diff --git a/docs/design/requirements/checkList.md b/docs/design/requirements/checkList.md new file mode 100644 index 000000000..47fe20a75 --- /dev/null +++ b/docs/design/requirements/checkList.md @@ -0,0 +1,46 @@ +### 🏷 Product / Brand 도메인 + +- [ ] 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다. +- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [ ] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 +- [ ] 재고의 음수 방지 처리는 도메인 레벨에서 처리된다 + +### 👍 Like 도메인 + +- [ ] 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다 +- [ ] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 +- [ ] 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다 + +### 🛒 Order 도메인 + +- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [ ] 주문 시 상품의 재고 차감을 수행한다 +- [ ] 재고 부족 예외 흐름을 고려해 설계되었다 +- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 + +### 🧩 도메인 서비스 + +- [ ] 도메인 내부 규칙은 Domain Service에 위치시켰다 +- [ ] 상품 상세 조회 시 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` 등) +- [ ] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 + +### 🎯 Feature Suggestions + +- 상품이 좋아요 수를 직접 관리해야 할까? +- 상품 상세에서 브랜드를 함께 제공하려면 누가 조합해야 할까? +- VO를 도입한 이유는 무엇이며, 어느 시점에서 유리하게 작용했는가? +- Order, Product, User 중 누가 어떤 책임을 갖는 것이 자연스러웠나? +- Repository Interface 를 Domain Layer에 두는 이유는? +- 처음엔 도메인에 두려 했지만, 결국 Application Layer로 옮긴 이유는? +- 테스트 가능한 구조를 만들기 위해 가장 먼저 고려한 건 무엇이었나? \ No newline at end of file diff --git a/docs/member-implementation-plan.md b/docs/member-implementation-plan.md new file mode 100644 index 000000000..0ace6419d --- /dev/null +++ b/docs/member-implementation-plan.md @@ -0,0 +1,455 @@ +# Member 기능 구현 계획 + +## 요구사항 상세 + +### 회원가입 +- **필요 정보**: loginId, password, name, birthDate, email +- 이미 가입된 loginId로는 가입 불가 +- 각 정보 포맷 검증 필요: + - **loginId**: 영문과 숫자만 허용 + - **name**: 필수값 + - **email**: 이메일 형식 + - **birthDate**: 날짜 형식 +- 비밀번호는 암호화해 저장 + +### 비밀번호 규칙 +- 8~16자 +- 영문 대소문자, 숫자, 특수문자만 허용 +- 생년월일 포함 불가 (YYYYMMDD, YYMMDD 형식 모두) +- (비밀번호 변경 시) 현재 비밀번호 재사용 불가 + +### 내 정보 조회 +- **인증 방식**: 헤더로 전달 + - `X-Loopers-LoginId`: 로그인 ID + - `X-Loopers-LoginPw`: 비밀번호 +- **반환 정보**: loginId, name, birthDate, email +- **마스킹**: 이름의 마지막 글자를 `*`로 마스킹 + - 예: "홍길동" → "홍길*" + +### 비밀번호 수정 +- **필요 정보**: 기존 비밀번호, 새 비밀번호 +- 기존 비밀번호 일치 확인 필수 +- 비밀번호 규칙 적용 + 현재 비밀번호 재사용 불가 + +--- + +## API 설계 + +| 기능 | Method | Endpoint | 인증 | +|------|--------|----------|------| +| 회원가입 | POST | `/api/v1/members` | 불필요 | +| 내정보조회 | GET | `/api/v1/members/me` | 헤더 인증 | +| 비밀번호변경 | PATCH | `/api/v1/members/me/password` | 헤더 인증 | + +### Request/Response 예시 + +#### 회원가입 +```http +POST /api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} +``` + +#### 내 정보 조회 +```http +GET /api/v1/members/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +``` +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "loginId": "testuser", + "name": "홍길*", + "birthDate": "1990-01-15", + "email": "test@example.com" + } +} +``` + +#### 비밀번호 변경 +```http +PATCH /api/v1/members/me/password +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass5678!" +} +``` + +--- + +## TDD 테스트 작성 전략 + +### 원칙: "가장 단순한 것" 또는 "가장 예외적인 것"부터 + +TDD에서 테스트 순서를 정하는 두 가지 접근법: + +| 접근법 | 설명 | 장점 | +|--------|------|------| +| **Simplest First** | 가장 단순한 성공 케이스부터 | 빠르게 동작하는 코드 확보 | +| **Edge First** | 가장 예외적인/경계 케이스부터 | 견고한 검증 로직 먼저 확보 | + +### 권장: 혼합 전략 (Zombie 방법론) + +``` +Z - Zero (빈 값, null) +O - One (단일 값, 정상 케이스 하나) +M - Many (여러 값, 경계값) +B - Boundary (경계 조건) +I - Interface (입출력 형식) +E - Exception (예외 상황) +``` + +**실제 적용 순서:** +1. **Zero/Null** → 가장 단순한 예외 (null, 빈 값) +2. **One** → 정상 동작 하나 +3. **Boundary** → 경계값 (8자, 16자 등) +4. **Exception** → 비즈니스 예외 (중복, 규칙 위반) + +--- + +## TDD 구현 순서 (상세) + +### Phase 1: PasswordValidator (단위 테스트) - 순수 Java + +**테스트 파일**: `PasswordValidatorTest.java` + +**작성 순서 (권장):** + +``` +1. [Zero] null 또는 빈 문자열 → BAD_REQUEST +2. [Boundary] 정확히 8자 → 성공 +3. [Boundary] 7자 (경계-1) → BAD_REQUEST +4. [Boundary] 정확히 16자 → 성공 +5. [Boundary] 17자 (경계+1) → BAD_REQUEST +6. [Exception] 허용되지 않는 문자 (한글) → BAD_REQUEST +7. [Exception] 생년월일 YYYYMMDD 포함 → BAD_REQUEST +8. [Exception] 생년월일 YYMMDD 포함 → BAD_REQUEST +9. [One] 모든 규칙 통과 → 성공 +``` + +| # | 테스트 메서드명 | 입력 예시 | 기대 결과 | +|---|----------------|----------|----------| +| 1 | `validate_WithNull_ThrowsBadRequest` | `null` | BAD_REQUEST | +| 2 | `validate_WithExactly8Chars_Succeeds` | `"Abcd123!"` | 성공 | +| 3 | `validate_With7Chars_ThrowsBadRequest` | `"Abc123!"` | BAD_REQUEST | +| 4 | `validate_WithExactly16Chars_Succeeds` | `"Abcd1234!@#$Efgh"` | 성공 | +| 5 | `validate_With17Chars_ThrowsBadRequest` | `"Abcd1234!@#$Efghi"` | BAD_REQUEST | +| 6 | `validate_WithKorean_ThrowsBadRequest` | `"Abcd123한글"` | BAD_REQUEST | +| 7 | `validate_ContainsBirthYYYYMMDD_ThrowsBadRequest` | `"Pass19900115!"` (생년월일: 1990-01-15) | BAD_REQUEST | +| 8 | `validate_ContainsBirthYYMMDD_ThrowsBadRequest` | `"Pass900115!!"` (생년월일: 1990-01-15) | BAD_REQUEST | +| 9 | `validate_WithValidPassword_Succeeds` | `"ValidPass1!"` | 성공 | + +### Phase 2: NameMasker (단위 테스트) - 순수 Java + +**테스트 파일**: `NameMaskerTest.java` + +**작성 순서:** + +``` +1. [Zero] null → null 반환 또는 예외 +2. [Zero] 빈 문자열 → 빈 문자열 +3. [Boundary] 1글자 → "*" +4. [Boundary] 2글자 → "홍*" +5. [One] 3글자 이상 → "홍길*" +``` + +| # | 테스트 메서드명 | 입력 | 기대 결과 | +|---|----------------|------|----------| +| 1 | `mask_WithNull_ReturnsNull` | `null` | `null` | +| 2 | `mask_WithEmpty_ReturnsEmpty` | `""` | `""` | +| 3 | `mask_With1Char_ReturnsMasked` | `"홍"` | `"*"` | +| 4 | `mask_With2Chars_ReturnsMasked` | `"홍길"` | `"홍*"` | +| 5 | `mask_With3Chars_ReturnsMasked` | `"홍길동"` | `"홍길*"` | + +### Phase 3: LoginIdValidator (단위 테스트) - 순수 Java + +**테스트 파일**: `LoginIdValidatorTest.java` + +**작성 순서:** + +``` +1. [Zero] null → BAD_REQUEST +2. [Zero] 빈 문자열 → BAD_REQUEST +3. [Exception] 특수문자 포함 → BAD_REQUEST +4. [Exception] 한글 포함 → BAD_REQUEST +5. [One] 영문+숫자 → 성공 +6. [One] 영문만 → 성공 +7. [One] 숫자만 → 성공 +``` + +### Phase 4: MemberModel (단위 테스트) + +**테스트 파일**: `MemberModelTest.java` + +**작성 순서:** + +``` +1. [Zero] loginId null → BAD_REQUEST +2. [Zero] name null → BAD_REQUEST +3. [One] 정상 생성 → 성공 +4. [One] changePassword 호출 → 비밀번호 변경됨 +``` + +| # | 테스트 메서드명 | 기대 결과 | +|---|----------------|----------| +| 1 | `create_WithNullLoginId_ThrowsBadRequest` | BAD_REQUEST | +| 2 | `create_WithNullName_ThrowsBadRequest` | BAD_REQUEST | +| 3 | `create_WithValidInput_Succeeds` | 성공 | +| 4 | `changePassword_UpdatesPassword` | 비밀번호 변경됨 | + +### Phase 5: MemberService (통합 테스트) + +**테스트 파일**: `MemberServiceIntegrationTest.java` + +**작성 순서:** + +``` +1. [One] 정상 회원가입 → 성공, 비밀번호 암호화됨 +2. [Exception] 중복 loginId → CONFLICT +3. [One] 존재하는 회원 조회 → 회원 반환 +4. [Exception] 존재하지 않는 회원 조회 → NOT_FOUND +5. [One] 헤더 인증 성공 → 회원 반환 +6. [Exception] 헤더 인증 실패 (비밀번호 불일치) → UNAUTHORIZED +7. [One] 정상 비밀번호 변경 → 성공 +8. [Exception] 현재 비밀번호 불일치 → BAD_REQUEST +9. [Exception] 새 비밀번호 규칙 위반 → BAD_REQUEST +``` + +### Phase 6: API E2E 테스트 + +**테스트 파일**: `MemberV1ApiE2ETest.java` + +**작성 순서:** + +``` +1. [One] POST 회원가입 성공 → 200 +2. [Exception] POST 중복 loginId → 409 +3. [Exception] POST 잘못된 loginId 형식 → 400 +4. [One] GET 내 정보 조회 성공 → 200, 이름 마스킹됨 +5. [Exception] GET 인증 실패 → 401 +6. [One] PATCH 비밀번호 변경 성공 → 200 +7. [Exception] PATCH 현재 비밀번호 불일치 → 400 +8. [Exception] PATCH 비밀번호 규칙 위반 → 400 +``` + +--- + +## 구현 파일 목록 + +### 1. 의존성 추가 +``` +apps/commerce-api/build.gradle.kts # spring-security-crypto 추가 +``` + +### 2. 설정 +``` +apps/commerce-api/src/main/java/com/loopers/config/ +└── PasswordEncoderConfig.java # BCryptPasswordEncoder Bean +``` + +### 3. Domain Layer +``` +apps/commerce-api/src/main/java/com/loopers/domain/member/ +├── MemberModel.java # 엔티티 (BaseEntity 확장) +├── MemberRepository.java # 저장소 인터페이스 +├── MemberService.java # 도메인 서비스 +├── PasswordValidator.java # 비밀번호 검증 +├── LoginIdValidator.java # 로그인ID 검증 +└── NameMasker.java # 이름 마스킹 +``` + +### 4. Application Layer +``` +apps/commerce-api/src/main/java/com/loopers/application/member/ +├── MemberFacade.java # 유스케이스 조율 +└── MemberInfo.java # DTO (record) +``` + +### 5. Infrastructure Layer +``` +apps/commerce-api/src/main/java/com/loopers/infrastructure/member/ +├── MemberJpaRepository.java # Spring Data JPA +└── MemberRepositoryImpl.java # Repository 구현체 +``` + +### 6. Interfaces Layer +``` +apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/ +├── MemberV1ApiSpec.java # Swagger 명세 +├── MemberV1Controller.java # REST Controller +└── MemberV1Dto.java # Request/Response DTO +``` + +### 7. HTTP 테스트 +``` +http/commerce-api/member-v1.http # API 테스트용 +``` + +--- + +## 핵심 구현 사항 + +### PasswordValidator.java +```java +public class PasswordValidator { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + public static void validate(String password, LocalDate birthDate) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + if (password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!ALLOWED_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + if (containsBirthDate(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + public static void validateForChange(String newPassword, LocalDate birthDate, + String currentEncodedPassword, PasswordEncoder encoder) { + validate(newPassword, birthDate); + if (encoder.matches(newPassword, currentEncodedPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호는 사용할 수 없습니다."); + } + } + + private static boolean containsBirthDate(String password, LocalDate birthDate) { + if (birthDate == null) return false; + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); // 19900115 + String yymmdd = yyyymmdd.substring(2); // 900115 + return password.contains(yyyymmdd) || password.contains(yymmdd); + } +} +``` + +### NameMasker.java +```java +public class NameMasker { + private static final char MASK_CHAR = '*'; + + public static String mask(String name) { + if (name == null) return null; + if (name.isEmpty()) return ""; + if (name.length() == 1) return String.valueOf(MASK_CHAR); + return name.substring(0, name.length() - 1) + MASK_CHAR; + } +} +``` + +### LoginIdValidator.java +```java +public class LoginIdValidator { + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + public static void validate(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (!PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 가능합니다."); + } + } +} +``` + +### MemberModel.java +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; // BCrypt 암호화 + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, + LocalDate birthDate, String email) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + // getters... +} +``` + +--- + +## 참조 파일 (기존 패턴) +- `domain/example/ExampleModel.java` - Entity 패턴 +- `domain/example/ExampleService.java` - Service 패턴 +- `interfaces/api/ExampleV1ApiE2ETest.java` - E2E 테스트 패턴 +- `modules/jpa/.../BaseEntity.java` - BaseEntity 구조 + +--- + +## 검증 방법 + +### 1. 단위 테스트 +```bash +./gradlew test --tests "*PasswordValidatorTest" +./gradlew test --tests "*NameMaskerTest" +./gradlew test --tests "*LoginIdValidatorTest" +./gradlew test --tests "*MemberModelTest" +``` + +### 2. 통합 테스트 +```bash +./gradlew test --tests "*MemberServiceIntegrationTest" +``` + +### 3. E2E 테스트 +```bash +./gradlew test --tests "*MemberV1ApiE2ETest" +``` + +### 4. HTTP 파일로 수동 테스트 +```bash +# 인프라 실행 +docker-compose -f ./docker/infra-compose.yml up + +# 앱 실행 후 http/commerce-api/member-v1.http 실행 +``` diff --git a/docs/plans/2026-03-05-layer-separation-refactoring.md b/docs/plans/2026-03-05-layer-separation-refactoring.md new file mode 100644 index 000000000..72a384e15 --- /dev/null +++ b/docs/plans/2026-03-05-layer-separation-refactoring.md @@ -0,0 +1,1449 @@ +# 아키텍처 레이어 분리 리팩토링 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Domain/Application/Presentation 레이어의 책임을 올바르게 분리한다. + +**Architecture:** +- **Domain**: Entity, VO, Repository 인터페이스만 존재. 비즈니스 의사결정이 필요한 경우에만 Domain Service 허용. +- **Application**: Service(CRUD+트랜잭션 조율) + Facade(다중 도메인 조합). Application DTO(Info/Result record)를 통해 Presentation에 데이터 전달. +- **Presentation(interfaces)**: Controller는 Application 레이어만 의존. Dto는 Application DTO로부터 변환. Domain 모델 직접 참조 금지. + +**Tech Stack:** Java 21, Spring Boot 3.4.4, JPA, JUnit 5 + +**판별 기준 (블로그 참고):** +- 비즈니스 의사결정을 내리는 코드 → Domain (Entity/VO/Domain Service) +- 의사결정을 조율하고 외부 세계와 상호작용하는 코드 → Application Service +- 여러 도메인 서비스를 조합하는 코드 → Application Facade + +--- + +## 현재 문제 요약 + +### 1. Service가 domain에 있지만 전부 Application Service +모든 Service가 Repository 의존 + 트랜잭션 관리 = 유스케이스 조율 역할. +비즈니스 의사결정은 Entity/VO가 이미 담당하고 있음. + +### 2. Controller가 domain Service를 직접 호출 (레이어 위반) +| Controller | domain Service 직접 호출 | +|---|---| +| `BrandV1Controller` | `BrandService` | +| `BrandAdminV1Controller` | `BrandService` | +| `ProductAdminV1Controller` | `ProductService` | +| `OrderV1Controller` | `OrderService`, `MemberAuthService` | +| `OrderAdminV1Controller` | `OrderService` | + +### 3. Presentation Dto가 Domain Model 직접 참조 +| Dto | domain 모델 직접 참조 | +|---|---| +| `BrandV1Dto.BrandResponse` | `BrandModel` | +| `BrandAdminV1Dto.BrandResponse` | `BrandModel` | +| `OrderV1Dto.OrderResponse` | `OrderModel`, `OrderItemModel` | +| `OrderV1Dto.OrderSummaryResponse` | `OrderModel` | +| `OrderAdminV1Dto.*` | `OrderModel`, `OrderItemModel` | + +--- + +## 리팩토링 후 목표 구조 + +``` +domain/ +├── brand/ +│ ├── BrandModel.java, BrandName.java, BrandRepository.java +├── example/ +│ ├── ExampleModel.java, ExampleRepository.java +├── like/ +│ ├── LikeModel.java, LikeRepository.java +├── member/ +│ ├── MemberModel.java, LoginId.java, Email.java, MemberName.java, Password.java +│ └── MemberRepository.java +├── order/ +│ ├── OrderModel.java, OrderItemModel.java, OrderStatus.java +│ └── OrderRepository.java, OrderItemRepository.java +├── product/ +│ ├── ProductModel.java, Money.java, ProductSortType.java, ProductRepository.java +└── stock/ + ├── StockModel.java, StockStatus.java, StockRepository.java + +application/ +├── brand/ +│ ├── BrandService.java (moved from domain) +│ ├── BrandFacade.java (cross-domain: brand + product 삭제) +│ └── BrandInfo.java +├── example/ +│ ├── ExampleService.java (moved from domain, ExampleFacade 합침) +│ └── ExampleInfo.java +├── like/ +│ ├── LikeService.java (moved from domain) +│ ├── LikeFacade.java (cross-domain: like + product) +│ └── LikeWithProduct.java +├── member/ +│ ├── MemberSignupService.java (moved from domain) +│ ├── MemberAuthService.java (moved from domain) +│ ├── MemberPasswordService.java (moved from domain) +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── order/ +│ ├── OrderService.java (moved from domain) +│ ├── OrderFacade.java (cross-domain: order + product + stock) +│ ├── OrderItemCommand.java, OrderResult.java +│ └── OrderInfo.java (NEW) +├── product/ +│ ├── ProductService.java (moved from domain) +│ ├── ProductFacade.java (cross-domain: product + brand + stock) +│ └── ProductDetail.java +└── stock/ + └── StockService.java (moved from domain) + +interfaces/api/ (변경사항: Controller → application만 의존, Dto → application DTO만 참조) +``` + +--- + +## Task 1: Example 도메인 (패턴 확립) + +가장 단순한 도메인으로 리팩토링 패턴을 확립한다. +ExampleFacade는 pass-through이므로 ExampleService에 합친다. + +**Files:** +- Move: `domain/example/ExampleService.java` → `application/example/ExampleService.java` +- Delete: `application/example/ExampleFacade.java` (ExampleService에 합침) +- Modify: `interfaces/api/example/ExampleV1Controller.java` +- Move test: `domain/example/ExampleServiceIntegrationTest.java` → `application/example/ExampleServiceIntegrationTest.java` + +**Step 1: ExampleService를 application으로 이동 + ExampleFacade 합침** + +`application/example/ExampleService.java`: +```java +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; +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 ExampleInfo getExample(Long id) { + ExampleModel example = exampleRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + return ExampleInfo.from(example); + } +} +``` + +**Step 2: ExampleV1Controller 수정 (Facade → Service)** + +```java +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleInfo; +import com.loopers.application.example.ExampleService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/examples") +public class ExampleV1Controller implements ExampleV1ApiSpec { + + private final ExampleService exampleService; + + @GetMapping("/{exampleId}") + @Override + public ApiResponse getExample( + @PathVariable(value = "exampleId") Long exampleId + ) { + ExampleInfo info = exampleService.getExample(exampleId); + ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); + return ApiResponse.success(response); + } +} +``` + +**Step 3: domain/example/ExampleService.java, application/example/ExampleFacade.java 삭제** + +**Step 4: 테스트 패키지 이동 + import 수정** +- `ExampleServiceIntegrationTest` → `application/example/` 로 이동, import 수정 + +**Step 5: 테스트 실행** + +```bash +./gradlew test --tests "*Example*" +``` +Expected: ALL PASS + +**Step 6: 커밋** + +```bash +git add -A +git commit -m "refactor: Example 도메인 레이어 분리 - Service를 application으로 이동, pass-through Facade 제거" +``` + +--- + +## Task 2: Stock 도메인 + +Controller 없음. Service 이동만 하면 됨. + +**Files:** +- Move: `domain/stock/StockService.java` → `application/stock/StockService.java` +- Modify: `application/product/ProductFacade.java` (import 변경) +- Modify: `application/order/OrderFacade.java` (import 변경) +- Move test: `domain/stock/StockServiceTest.java` → `application/stock/StockServiceTest.java` + +**Step 1: StockService를 application으로 이동** + +`application/stock/StockService.java`: +```java +package com.loopers.application.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class StockService { + + private final StockRepository stockRepository; + + @Transactional + public StockModel create(Long productId, int quantity) { + StockModel stock = new StockModel(productId, quantity); + return stockRepository.save(stock); + } + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "재고를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Map getByProductIds(List productIds) { + return stockRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + } +} +``` + +**Step 2: ProductFacade, OrderFacade import 수정** + +```java +// 변경 전 +import com.loopers.domain.stock.StockService; +// 변경 후 +import com.loopers.application.stock.StockService; +``` + +**Step 3: domain/stock/StockService.java 삭제** + +**Step 4: StockServiceTest 패키지 이동 + import 수정** + +**Step 5: 테스트 실행** + +```bash +./gradlew test --tests "*Stock*" --tests "*Product*" --tests "*Order*" +``` +Expected: ALL PASS + +**Step 6: 커밋** + +```bash +git commit -m "refactor: Stock 도메인 레이어 분리 - StockService를 application으로 이동" +``` + +--- + +## Task 3: Like 도메인 + +**Files:** +- Move: `domain/like/LikeService.java` → `application/like/LikeService.java` +- Modify: `application/like/LikeFacade.java` (import 변경) +- Move test: (LikeService 단독 테스트 없음, LikeFacadeIntegrationTest만 존재) + +**Step 1: LikeService를 application으로 이동** + +`application/like/LikeService.java`: +```java +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +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.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + public LikeModel save(LikeModel like) { + return likeRepository.save(like); + } + + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + public Optional findActiveLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} +``` + +**Step 2: LikeFacade import 수정** + +```java +// 변경 전 +import com.loopers.domain.like.LikeService; +// 변경 후 +// LikeService가 같은 패키지(application.like)에 있으므로 import 불필요 +``` + +**Step 3: domain/like/LikeService.java 삭제** + +**Step 4: 테스트 실행** + +```bash +./gradlew test --tests "*Like*" +``` +Expected: ALL PASS + +**Step 5: 커밋** + +```bash +git commit -m "refactor: Like 도메인 레이어 분리 - LikeService를 application으로 이동" +``` + +--- + +## Task 4: Brand 도메인 + +이 도메인부터 Presentation 레이어 위반도 함께 수정한다. + +**문제점:** +1. `BrandV1Controller` → `BrandService` 직접 호출 +2. `BrandAdminV1Controller` → `BrandService` + `BrandFacade` 혼용 +3. `BrandV1Dto.BrandResponse.from(BrandModel)` → domain 모델 직접 참조 +4. `BrandAdminV1Dto.BrandResponse.from(BrandModel)` → domain 모델 직접 참조 + +**Files:** +- Move: `domain/brand/BrandService.java` → `application/brand/BrandService.java` +- Modify: `application/brand/BrandFacade.java` (import 변경) +- Modify: `interfaces/api/brand/BrandV1Controller.java` (BrandFacade 경유) +- Modify: `interfaces/api/brand/BrandV1Dto.java` (BrandInfo로 변환) +- Modify: `interfaces/api/brand/admin/BrandAdminV1Controller.java` (BrandFacade만 의존) +- Modify: `interfaces/api/brand/admin/BrandAdminV1Dto.java` (BrandInfo로 변환) +- Move tests: `domain/brand/BrandServiceTest.java`, `BrandServiceIntegrationTest.java` → `application/brand/` + +**Step 1: BrandService를 application으로 이동** + +`application/brand/BrandService.java`: +```java +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); + + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + + BrandModel brand = new BrandModel(brandName, description); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandModel getBrand(Long brandId) { + BrandModel brand = findById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return brand; + } + + @Transactional(readOnly = true) + public BrandModel getBrandForAdmin(Long brandId) { + return findById(brandId); + } + + @Transactional + public BrandModel update(Long brandId, String name, String description) { + BrandModel brand = findById(brandId); + BrandName newName = new BrandName(name); + + if (!brand.name().equals(newName)) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + } + + brand.update(newName, description); + return brand; + } + + @Transactional + public void delete(Long brandId) { + BrandModel brand = findById(brandId); + brand.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } +} +``` + +**Step 2: BrandFacade 수정 - 누락된 메서드 추가 (Controller가 Facade만 호출하도록)** + +`application/brand/BrandFacade.java`: +```java +package com.loopers.application.brand; + +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description) { + return BrandInfo.from(brandService.register(name, description)); + } + + public BrandInfo getBrand(Long brandId) { + return BrandInfo.from(brandService.getBrand(brandId)); + } + + public BrandInfo getBrandForAdmin(Long brandId) { + return BrandInfo.from(brandService.getBrandForAdmin(brandId)); + } + + public BrandInfo update(Long brandId, String name, String description) { + return BrandInfo.from(brandService.update(brandId, name, description)); + } + + @Transactional + public void delete(Long brandId) { + brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } +} +``` + +**Step 3: BrandV1Controller 수정 - BrandFacade만 의존** + +```java +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} +``` + +**Step 4: BrandV1Dto 수정 - BrandInfo로 변환** + +```java +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} +``` + +**Step 5: BrandAdminV1Controller 수정 - BrandFacade만 의존** + +```java +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result = brandFacade.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { + BrandInfo info = brandFacade.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} +``` + +**Step 6: BrandAdminV1Dto 수정 - BrandInfo로 변환** + +```java +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandInfo; + +import java.time.ZonedDateTime; + +public class BrandAdminV1Dto { + + public record CreateRequest(String name, String description) {} + + public record UpdateRequest(String name, String description) {} + + public record BrandResponse( + Long id, String name, String description, + ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt + ) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.id(), info.name(), info.description(), + info.createdAt(), info.updatedAt(), info.deletedAt() + ); + } + } +} +``` + +**Step 7: domain/brand/BrandService.java 삭제** + +**Step 8: 테스트 패키지 이동 + import 수정** +- `BrandServiceTest.java` → `application/brand/BrandServiceTest.java` +- `BrandServiceIntegrationTest.java` → `application/brand/BrandServiceIntegrationTest.java` + +**Step 9: 테스트 실행** + +```bash +./gradlew test --tests "*Brand*" +``` +Expected: ALL PASS + +**Step 10: 커밋** + +```bash +git commit -m "refactor: Brand 도메인 레이어 분리 - Service를 application으로 이동, Controller는 Facade만 의존, Dto는 BrandInfo로 변환" +``` + +--- + +## Task 5: Product 도메인 + +**문제점:** +1. `ProductAdminV1Controller` → `ProductService` + `ProductFacade` 혼용 +2. ProductFacade에 없는 update/delete 메서드를 Controller에서 ProductService 직접 호출 + +**Files:** +- Move: `domain/product/ProductService.java` → `application/product/ProductService.java` +- Modify: `application/product/ProductFacade.java` (import + update/delete 메서드 추가) +- Modify: `interfaces/api/product/admin/ProductAdminV1Controller.java` (ProductFacade만 의존) +- Move tests: `domain/product/ProductServiceTest.java`, `ProductServiceIntegrationTest.java` → `application/product/` + +**Step 1: ProductService를 application으로 이동** + +`application/product/ProductService.java` — 기존 코드 그대로, 패키지만 변경: +```java +package com.loopers.application.product; +// ... (기존 코드 동일, import만 domain.product.* 유지) +``` + +**Step 2: ProductFacade에 update/delete 메서드 추가** + +```java +// ProductFacade에 추가 +@Transactional +public ProductDetail update(Long productId, String name, String description, Money price) { + productService.update(productId, name, description, price); + return getProductForAdmin(productId); +} + +public void delete(Long productId) { + productService.delete(productId); +} +``` + +**Step 3: ProductAdminV1Controller 수정 - ProductFacade만 의존** + +```java +package com.loopers.interfaces.api.product.admin; + +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Money; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + var product = productFacade.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.initialStock() + ); + ProductDetail detail = productFacade.getProductForAdmin(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long brandId + ) { + Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); + return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @AdminUser AdminInfo admin, + @PathVariable Long productId + ) { + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(); + } +} +``` + +**Step 4: domain/product/ProductService.java 삭제** + +**Step 5: 테스트 패키지 이동 + import 수정** + +**Step 6: 테스트 실행** + +```bash +./gradlew test --tests "*Product*" +``` +Expected: ALL PASS + +**Step 7: 커밋** + +```bash +git commit -m "refactor: Product 도메인 레이어 분리 - Service를 application으로 이동, AdminController는 Facade만 의존" +``` + +--- + +## Task 6: Order 도메인 + +**문제점:** +1. `OrderV1Controller` → `OrderService`, `MemberAuthService` 직접 호출 +2. `OrderAdminV1Controller` → `OrderService` 직접 호출 +3. `OrderV1Dto`, `OrderAdminV1Dto` → `OrderModel`, `OrderItemModel` 직접 참조 +4. `OrderModel`에 소유권 검증 로직이 Service에 새어나옴 + +**Files:** +- Move: `domain/order/OrderService.java` → `application/order/OrderService.java` +- Modify: `domain/order/OrderModel.java` (validateOwner 추가) +- Create: `application/order/OrderInfo.java` (NEW) +- Modify: `application/order/OrderFacade.java` (조회 메서드 추가) +- Modify: `interfaces/api/order/OrderV1Controller.java` +- Modify: `interfaces/api/order/OrderV1Dto.java` +- Modify: `interfaces/api/order/admin/OrderAdminV1Controller.java` +- Modify: `interfaces/api/order/admin/OrderAdminV1Dto.java` + +**Step 1: OrderModel에 소유권 검증 메서드 추가** + +`domain/order/OrderModel.java`에 추가: +```java +public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } +} +``` + +**Step 2: OrderInfo 생성 (application DTO)** + +`application/order/OrderInfo.java`: +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt +) { + + public static OrderInfo from(OrderModel order, List items) { + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), itemInfos, order.getCreatedAt() + ); + } + + public static OrderInfo summaryFrom(OrderModel order) { + return new OrderInfo( + order.getId(), order.userId(), order.status().name(), + order.totalAmount().value(), List.of(), order.getCreatedAt() + ); + } + + public record OrderItemInfo( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + ); + } + } +} +``` + +**Step 3: OrderService를 application으로 이동 + validateOwner 사용** + +`application/order/OrderService.java`: +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional + public List saveAllItems(List orderItems) { + return orderItemRepository.saveAll(orderItems); + } + + @Transactional(readOnly = true) + public OrderModel getOrder(Long orderId, Long userId) { + OrderModel order = findById(orderId); + order.validateOwner(userId); + return order; + } + + @Transactional(readOnly = true) + public OrderModel getOrderForAdmin(Long orderId) { + return findById(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + private OrderModel findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } +} +``` + +**Step 4: OrderFacade에 조회 메서드 추가** + +```java +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductService; +import com.loopers.application.stock.StockService; +import com.loopers.domain.stock.StockModel; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + + @Transactional + public OrderResult placeOrder(Long userId, List commands) { + Money totalAmount = Money.ZERO; + List snapshots = new ArrayList<>(); + + for (OrderItemCommand cmd : commands) { + ProductModel product = productService.getProduct(cmd.productId()); + + StockModel stock = stockService.getByProductId(cmd.productId()); + stock.decrease(cmd.quantity()); + + Money subtotal = product.price().multiply(cmd.quantity()); + totalAmount = totalAmount.add(subtotal); + + snapshots.add(new SnapshotHolder( + product.getId(), product.name(), product.price(), cmd.quantity() + )); + } + + OrderModel order = orderService.save(new OrderModel(userId, totalAmount)); + + List items = snapshots.stream() + .map(s -> new OrderItemModel( + order.getId(), s.productId(), s.productName(), s.productPrice(), s.quantity() + )) + .toList(); + + List savedItems = orderService.saveAllItems(items); + + return OrderResult.of(order, savedItems); + } + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long orderId, Long userId) { + OrderModel order = orderService.getOrder(orderId, userId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + List orders = orderService.getOrdersByUser(userId, startAt, endAt); + return orders.stream() + .map(OrderInfo::summaryFrom) + .toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderForAdmin(Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + Page orders = orderService.getAllForAdmin(pageable); + return orders.map(OrderInfo::summaryFrom); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} +``` + +**Step 5: OrderV1Controller 수정 - OrderFacade만 의존** + +```java +package com.loopers.interfaces.api.order; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + private final MemberFacade memberFacade; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + @RequestBody OrderV1Dto.CreateRequest request, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + return ApiResponse.success(OrderV1Dto.OrderResponse.fromResult(result)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse> getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + + List orders = orderFacade.getOrdersByUser(member.getId(), start, end); + List response = orders.stream() + .map(OrderV1Dto.OrderSummaryResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + @PathVariable Long orderId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberFacade.authenticate(loginId, password); + OrderInfo info = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} +``` + +**Step 6: OrderV1Dto 수정 - OrderInfo로 변환** + +```java +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderResult; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest(List items) { + public List toCommands() { + return items.stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest(Long productId, int quantity) {} + + public record OrderResponse( + Long orderId, String status, int totalAmount, + List items, ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.status(), info.totalAmount(), items, info.createdAt() + ); + } + + public static OrderResponse fromResult(OrderResult result) { + List items = result.items().stream() + .map(item -> new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice().value(), item.quantity(), item.subtotal().value() + )) + .toList(); + return new OrderResponse( + result.order().getId(), result.order().status().name(), + result.order().totalAmount().value(), items, result.order().getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, String status, int totalAmount, ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.status(), info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} +``` + +**Step 7: OrderAdminV1Controller 수정 - OrderFacade만 의존** + +```java +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); + return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderForAdmin(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); + } +} +``` + +**Step 8: OrderAdminV1Dto 수정 - OrderInfo로 변환** + +```java +package com.loopers.interfaces.api.order.admin; + +import com.loopers.application.order.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderResponse( + Long orderId, Long userId, String status, int totalAmount, + List items, ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), items, info.createdAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, Long userId, String status, int totalAmount, ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), info.userId(), info.status(), + info.totalAmount(), info.createdAt() + ); + } + } + + public record OrderItemResponse( + Long productId, String productName, int productPrice, int quantity, int subtotal + ) { + public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), item.productName(), + item.productPrice(), item.quantity(), item.subtotal() + ); + } + } +} +``` + +**Step 9: domain/order/OrderService.java 삭제** + +**Step 10: 테스트 실행** + +```bash +./gradlew test --tests "*Order*" +``` +Expected: ALL PASS + +**Step 11: 커밋** + +```bash +git commit -m "refactor: Order 도메인 레이어 분리 - Service를 application으로 이동, validateOwner를 엔티티로 이관, Controller/Dto가 OrderInfo 경유" +``` + +--- + +## Task 7: Member 도메인 + +**문제점:** +1. `MemberAuthService`가 `domain/`에 있지만 application service +2. `OrderV1Controller`가 `MemberAuthService` 직접 호출 → Task 6에서 `MemberFacade.authenticate()` 경유로 변경 +3. `MemberFacade`에 `authenticate()` 메서드 추가 필요 + +**Files:** +- Move: `domain/member/MemberSignupService.java` → `application/member/MemberSignupService.java` +- Move: `domain/member/MemberAuthService.java` → `application/member/MemberAuthService.java` +- Move: `domain/member/MemberPasswordService.java` → `application/member/MemberPasswordService.java` +- Modify: `application/member/MemberFacade.java` (authenticate 추가) +- Move tests: 6개 테스트 파일 이동 + +**Step 1: MemberSignupService를 application으로 이동** + +`application/member/MemberSignupService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 2: MemberAuthService를 application으로 이동** + +`application/member/MemberAuthService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 3: MemberPasswordService를 application으로 이동** + +`application/member/MemberPasswordService.java`: +```java +package com.loopers.application.member; +// ... (기존 코드 동일, 패키지만 변경) +``` + +**Step 4: MemberFacade에 authenticate() 추가** + +```java +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberSignupService memberSignupService; + private final MemberAuthService memberAuthService; + private final MemberPasswordService memberPasswordService; + + public MemberInfo signup(String loginId, String password, String name, + LocalDate birthDate, String email) { + MemberModel member = memberSignupService.signup(loginId, password, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberModel authenticate(String loginId, String password) { + return memberAuthService.authenticate(loginId, password); + } + + public MemberInfo getMyInfo(MemberModel member) { + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(MemberModel member, String currentPassword, String newPassword) { + memberPasswordService.changePassword(member, currentPassword, newPassword); + } +} +``` + +**Step 5: domain/member/ 에서 3개 Service 파일 삭제** + +**Step 6: 테스트 패키지 이동 (6개 파일)** +- `MemberSignupServiceTest.java` → `application/member/` +- `MemberSignupServiceIntegrationTest.java` → `application/member/` +- `MemberAuthServiceTest.java` → `application/member/` +- `MemberAuthServiceIntegrationTest.java` → `application/member/` +- `MemberPasswordServiceTest.java` → `application/member/` +- `MemberPasswordServiceIntegrationTest.java` → `application/member/` + +**Step 7: 테스트 실행** + +```bash +./gradlew test --tests "*Member*" +``` +Expected: ALL PASS + +**Step 8: 커밋** + +```bash +git commit -m "refactor: Member 도메인 레이어 분리 - 3개 Service를 application으로 이동, MemberFacade에 authenticate 추가" +``` + +--- + +## Task 8: 전체 테스트 + 레이어 위반 검증 + +**Step 1: 전체 테스트 실행** + +```bash +./gradlew test +``` +Expected: ALL PASS + +**Step 2: 레이어 위반 확인 - domain에 Service가 남아있지 않은지** + +```bash +# domain 패키지에 Service 클래스가 없어야 함 +find apps/commerce-api/src/main/java/com/loopers/domain -name "*Service*.java" -type f +``` +Expected: 0 results + +**Step 3: 레이어 위반 확인 - Controller가 domain Service를 import하지 않는지** + +```bash +# interfaces 패키지에서 domain import 중 Service import가 없어야 함 +grep -r "import com.loopers.domain.*Service" apps/commerce-api/src/main/java/com/loopers/interfaces/ +``` +Expected: 0 results + +**Step 4: 레이어 위반 확인 - Dto가 domain Model을 직접 참조하지 않는지** + +```bash +# Dto 파일에서 domain 모델 import가 없어야 함 (Money 같은 VO는 예외 가능) +grep -r "import com.loopers.domain.*Model" apps/commerce-api/src/main/java/com/loopers/interfaces/ +``` +Expected: MemberModel만 남음 (LoginMember 어노테이션 때문에 Controller에서 사용, 이건 인증 인프라 문제로 별도 처리) + +**Step 5: 최종 커밋** + +```bash +git commit -m "refactor: 아키텍처 레이어 분리 완료 - 전체 테스트 통과 및 레이어 위반 검증" +``` + +--- + +## 참고: 리팩토링 후 의존 방향 + +``` +interfaces/api (Presentation) + ↓ depends on +application (Service + Facade + Info/Result DTOs) + ↓ depends on +domain (Entity + VO + Repository interface) + ↑ implements +infrastructure (JPA Repository 구현체) +``` + +**Controller 의존 규칙:** +- Controller → Facade (다중 도메인 조합이 필요한 경우) +- Controller → Service (단일 도메인이고 Facade가 불필요한 경우) — 현재 해당 없음, 모든 Controller가 Facade 경유 +- Controller ✗ domain Service/Model 직접 참조 금지 + +**Dto 변환 규칙:** +- Dto.from(Info/Result record) — application DTO로부터 변환 +- Dto ✗ from(Entity/Model) 금지 diff --git a/docs/requirements/api-spec.md b/docs/requirements/api-spec.md new file mode 100644 index 000000000..1f2f5cbb7 --- /dev/null +++ b/docs/requirements/api-spec.md @@ -0,0 +1,154 @@ +## 🎯 배경 + +**좋아요** 누르고, **쿠폰** 쓰고, 주문 및 **결제**하는 **감성 이커머스**. + +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결돼요. + +우린 이 흐름을 하나씩 직접 만들어갈 거예요. + +--- + +## 🧭 서비스 흐름 예시 + +1. 사용자가 **회원가입**을 하고 +2. 여러 브랜드의 상품을 둘러보고, 마음에 드는 상품엔 **좋아요**를 누르죠. +3. 사용자는 **쿠폰을 발급**받고, 여러 상품을 **한 번에 주문하고 결제**합니다. +4. 유저의 행동은 모두 기록되고, 그 데이터는 이후 다양한 기능으로 확장될 수 있어요. + +--- + +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +--- + +### 📡 나아가며 + +> ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** +> \ No newline at end of file diff --git a/docs/requirements/checkList.md b/docs/requirements/checkList.md new file mode 100644 index 000000000..47fe20a75 --- /dev/null +++ b/docs/requirements/checkList.md @@ -0,0 +1,46 @@ +### 🏷 Product / Brand 도메인 + +- [ ] 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다. +- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [ ] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 +- [ ] 재고의 음수 방지 처리는 도메인 레벨에서 처리된다 + +### 👍 Like 도메인 + +- [ ] 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다 +- [ ] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 +- [ ] 단위 테스트에서 좋아요 등록/취소 흐름을 검증했다 + +### 🛒 Order 도메인 + +- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [ ] 주문 시 상품의 재고 차감을 수행한다 +- [ ] 재고 부족 예외 흐름을 고려해 설계되었다 +- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 + +### 🧩 도메인 서비스 + +- [ ] 도메인 내부 규칙은 Domain Service에 위치시켰다 +- [ ] 상품 상세 조회 시 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` 등) +- [ ] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 + +### 🎯 Feature Suggestions + +- 상품이 좋아요 수를 직접 관리해야 할까? +- 상품 상세에서 브랜드를 함께 제공하려면 누가 조합해야 할까? +- VO를 도입한 이유는 무엇이며, 어느 시점에서 유리하게 작용했는가? +- Order, Product, User 중 누가 어떤 책임을 갖는 것이 자연스러웠나? +- Repository Interface 를 Domain Layer에 두는 이유는? +- 처음엔 도메인에 두려 했지만, 결국 Application Layer로 옮긴 이유는? +- 테스트 가능한 구조를 만들기 위해 가장 먼저 고려한 건 무엇이었나? \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 diff --git a/http/commerce-api/brand-admin-v1.http b/http/commerce-api/brand-admin-v1.http new file mode 100644 index 000000000..be8a78aa0 --- /dev/null +++ b/http/commerce-api/brand-admin-v1.http @@ -0,0 +1,31 @@ +### 브랜드 등록 +POST {{commerce-api}}/api-admin/v1/brands +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드" +} + +### 브랜드 목록 조회 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=10 +X-Loopers-AdminLdap: admin + +### 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin + +### 브랜드 수정 +PUT {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "아디다스", + "description": "독일 스포츠 브랜드" +} + +### 브랜드 삭제 +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-AdminLdap: admin diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..3a3a35c55 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,29 @@ +### [Customer] 브랜드 조회 +GET {{commerce-api}}/api/v1/brands/1 + +### [Admin] 브랜드 등록 +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드" +} + +### [Admin] 브랜드 목록 조회 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=10 + +### [Admin] 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 + +### [Admin] 브랜드 수정 +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json + +{ + "name": "뉴발란스", + "description": "라이프스타일 브랜드" +} + +### [Admin] 브랜드 삭제 +DELETE {{commerce-api}}/api-admin/v1/brands/1 diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..3cf72885f --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,14 @@ +### 상품 좋아요 등록 +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 상품 좋아요 취소 +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 내가 좋아요한 상품 목록 조회 +GET {{commerce-api}}/api/v1/users/1/likes?page=0&size=10 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..a11405a78 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,28 @@ +### 주문 생성 +POST {{commerce-api}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 1 } + ] +} + +### 내 주문 목록 조회 (기간별) +GET {{commerce-api}}/api/v1/orders?startAt=2026-01-01&endAt=2026-12-31 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 주문 상세 조회 +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### [ADMIN] 주문 목록 조회 +GET {{commerce-api}}/api-admin/v1/orders?page=0&size=20 + +### [ADMIN] 주문 상세 조회 +GET {{commerce-api}}/api-admin/v1/orders/1 diff --git a/http/commerce-api/product-admin-v1.http b/http/commerce-api/product-admin-v1.http new file mode 100644 index 000000000..ac27b3a80 --- /dev/null +++ b/http/commerce-api/product-admin-v1.http @@ -0,0 +1,44 @@ +### 상품 등록 +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "에어맥스 90", + "description": "클래식 러닝화", + "price": 129000, + "brandId": 1, + "stockQuantity": 100 +} + +### 상품 목록 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/products?page=0&size=10&sortType=CREATED_DESC +X-Loopers-AdminLdap: admin + +### 상품 상세 조회 (관리자) +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin + +### 상품 수정 +PUT {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "name": "에어맥스 95", + "description": "프리미엄 러닝화", + "price": 179000 +} + +### 재고 수정 +PATCH {{commerce-api}}/api-admin/v1/products/1/stock +X-Loopers-AdminLdap: admin +Content-Type: application/json + +{ + "quantity": 50 +} + +### 상품 삭제 +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-AdminLdap: admin diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..e48387c35 --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,11 @@ +### 상품 목록 조회 (최신순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=CREATED_DESC + +### 상품 목록 조회 (가격 낮은순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=PRICE_ASC + +### 상품 목록 조회 (좋아요 많은순) +GET {{commerce-api}}/api/v1/products?page=0&size=10&sortType=LIKES_DESC + +### 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1