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..d547a18a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# 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 + +## 개발 규칙 +### 진행 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..5344e643d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..3901151c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,63 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + @Transactional + public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + if (existing.isEmpty()) { + likeService.save(new LikeModel(userId, productId)); + product.incrementLikeCount(); + } else if (existing.get().getDeletedAt() != null) { + existing.get().restore(); + product.incrementLikeCount(); + } + // else: 이미 활성 좋아요 존재 → 멱등, 아무것도 안 함 + } + + @Transactional + public void unlike(Long userId, Long productId) { + Optional existing = likeService.findActiveLike(userId, productId); + + if (existing.isPresent()) { + existing.get().delete(); + ProductModel product = productService.getProduct(existing.get().productId()); + product.decrementLikeCount(); + } + // else: 좋아요가 없음 → 멱등, 아무것도 안 함 + } + + @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, product); + }); + } +} 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..5da08528b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java @@ -0,0 +1,9 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +public record LikeWithProduct( + LikeModel like, + ProductModel product +) {} 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..b06c9c47d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,31 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberPasswordService; +import com.loopers.domain.member.MemberSignupService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberSignupService memberSignupService; + 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 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/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..58f867cef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +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); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} 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/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..1f5b0d8d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,77 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.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()); + }); + } + + 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/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..c3183b6f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,38 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Embedded + private BrandName name; + + @Column(name = "description") + private String description; + + protected BrandModel() {} + + public BrandModel(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public void update(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public BrandName name() { + return name; + } + + public String description() { + return description; + } +} 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..f83f3ec35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,20 @@ +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 { + + BrandModel save(BrandModel brand); + + 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/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..0691e2de2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,73 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@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, "브랜드를 찾을 수 없습니다.")); + } +} 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/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..27571279b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.like; + +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/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/MemberAuthService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java new file mode 100644 index 000000000..f90f86e5a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.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/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/MemberPasswordService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java new file mode 100644 index 000000000..76e9d93f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.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/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/MemberSignupService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java new file mode 100644 index 000000000..89505937d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.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/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..9c9b89de8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,55 @@ +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; + + protected OrderModel() { + } + + public OrderModel(Long userId, Money totalAmount) { + this.userId = userId; + this.status = OrderStatus.CREATED; + this.totalAmount = totalAmount; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문자 정보는 필수입니다."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 총액은 필수입니다."); + } + } + + public Long userId() { + return userId; + } + + public OrderStatus status() { + return status; + } + + public Money totalAmount() { + return totalAmount; + } +} 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/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..d779d2ea1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + 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); + if (!order.userId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); + } + 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/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..121376a04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,51 @@ +package com.loopers.domain.product; + +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 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 int value() { + return value; + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money multiply(int multiplier) { + return new Money(this.value * multiplier); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money that)) return false; + return value == that.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..84a5167c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,73 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +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(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Embedded + 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) { + this.name = name; + this.description = description; + this.price = price; + this.brandId = brandId; + this.likeCount = 0; + } + + public void update(String name, String description, Money price) { + this.name = name; + this.description = description; + this.price = price; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public Money price() { + return price; + } + + public Long brandId() { + return brandId; + } + + public int likeCount() { + return likeCount; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } +} 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..a9085b013 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,26 @@ +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); + + Optional findById(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAll(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/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..702474e28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,90 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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 getProduct(Long productId) { + ProductModel product = findById(productId); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + return product; + } + + @Transactional(readOnly = true) + public ProductModel getProductForAdmin(Long productId) { + return findById(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); + if (brandId != null) { + return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, sortedPageable); + } + return productRepository.findAllByDeletedAtIsNull(sortedPageable); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + if (brandId != null) { + return productRepository.findAllByBrandId(brandId, pageable); + } + return productRepository.findAll(pageable); + } + + @Transactional + public ProductModel update(Long productId, String name, String description, Money price) { + ProductModel product = findById(productId); + product.update(name, description, price); + return product; + } + + @Transactional + public void delete(Long productId) { + ProductModel product = findById(productId); + product.delete(); + } + + @Transactional + public void deleteAllByBrandId(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())); + } + + 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/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..cf0722075 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Sort; + +public enum ProductSortType { + LATEST(Sort.by(Sort.Direction.DESC, "createdAt")), + PRICE_ASC(Sort.by(Sort.Direction.ASC, "price.value")), + PRICE_DESC(Sort.by(Sort.Direction.DESC, "price.value")), + LIKES_DESC(Sort.by(Sort.Direction.DESC, "likeCount")); + + private final Sort sort; + + ProductSortType(Sort sort) { + this.sort = sort; + } + + public Sort toSort() { + return sort; + } +} 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..1eac7b79c --- /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; + +@Entity +@Table(name = "stock") +public class StockModel extends BaseEntity { + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected StockModel() {} + + public StockModel(Long productId, int quantity) { + this.productId = productId; + this.quantity = quantity; + } + + public void decrease(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.quantity -= amount; + } + + public void increase(int amount) { + this.quantity += amount; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + public StockStatus toStatus() { + return StockStatus.from(this.quantity); + } + + public Long productId() { + return productId; + } + + public int quantity() { + return quantity; + } +} 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..78e333f4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.stock; + +import java.util.List; +import java.util.Optional; + +public interface StockRepository { + + StockModel save(StockModel stock); + + Optional findByProductId(Long productId); + + List findAllByProductIdIn(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java new file mode 100644 index 000000000..05cdbcd4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.stock; + +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)); + } +} 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..35ba50009 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +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..26ead19f0 --- /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 BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameValue(name); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAll(pageable); + } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } +} 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..c0b66f92a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,21 @@ +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 java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + 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..eab1cd675 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.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 Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandId(brandId, pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @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..b378a8822 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface StockJpaRepository extends JpaRepository { + + Optional findByProductId(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..e3335aac9 --- /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 StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + + @Override + public Optional findByProductId(Long productId) { + return stockJpaRepository.findByProductId(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/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..380c2b8e6 --- /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.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +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 BrandService brandService; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandModel brand = brandService.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + } +} 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..7f969dcf8 --- /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.domain.brand.BrandModel; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandModel model) { + return new BrandResponse(model.getId(), model.name().value(), model.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..391cb54d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +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 BrandService brandService; + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandModel brand = brandService.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @GetMapping + public ApiResponse> getAll( + @AdminUser AdminInfo admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result = brandService.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @AdminUser AdminInfo admin, + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandModel brand = brandService.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @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..dc88693d4 --- /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.domain.brand.BrandModel; + +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(BrandModel model) { + return new BrandResponse( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } + } +} 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..cf4ba7de5 --- /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(lwp -> LikeV1Dto.LikeResponse.from(lwp.like(), lwp.product()))); + } +} 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..b6a8a0aa4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeModel like, ProductModel product) { + return new LikeResponse( + like.getId(), + product.getId(), + product.name(), + product.price().value(), + like.getCreatedAt() + ); + } + } +} 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/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..8cc7b9b25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +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.RequestHeader; +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; + private final OrderService orderService; + private final MemberAuthService memberAuthService; + + @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 = memberAuthService.authenticate(loginId, password); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(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 = memberAuthService.authenticate(loginId, password); + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + + List orders = orderService.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 = memberAuthService.authenticate(loginId, password); + OrderModel order = orderService.getOrder(orderId, member.getId()); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(order, items)); + } +} 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..548109abf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +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(OrderResult result) { + List items = result.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + result.order().getId(), + result.order().status().name(), + result.order().totalAmount().value(), + items, + result.order().getCreatedAt() + ); + } + + public static OrderResponse from(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} 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..a174c61e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderService orderService; + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderService.getAllForAdmin(PageRequest.of(page, size)); + Page response = orders.map( + OrderAdminV1Dto.OrderSummaryResponse::from + ); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(order, items)); + } +} 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..d5cf035a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java @@ -0,0 +1,72 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +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(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} 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..c6cfd3907 --- /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.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + 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..29839c56d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetail; +import com.loopers.domain.stock.StockStatus; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus 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..34b65cbc6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -0,0 +1,81 @@ +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.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +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; + private final ProductService productService; + + @PostMapping + public ApiResponse create( + @AdminUser AdminInfo admin, + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + ProductModel 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 + ) { + productService.update(productId, request.name(), request.description(), new Money(request.price())); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { + productService.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..21898e1b1 --- /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.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 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/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..34570fd9e --- /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.domain.brand.BrandService; +import com.loopers.domain.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/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java new file mode 100644 index 000000000..5e1389470 --- /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.domain.brand.BrandService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.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/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 000000000..acc6623ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,105 @@ +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 MemberPasswordService memberPasswordService; + + private MemberFacade memberFacade; + + @BeforeEach + void setUp() { + memberFacade = new MemberFacade(memberSignupService, 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/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..fdf72c473 --- /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.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.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))); + 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))); + 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))); + 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)))); + 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)))); + 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)))); + 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))); + 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))); + 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))); + 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))); + 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/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..311563551 --- /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.domain.brand.BrandService; +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.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/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..9c13a938f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.brand; + +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; + +class BrandModelTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + BrandName name = new BrandName("나이키"); + String description = "스포츠 브랜드"; + + // when + BrandModel brand = new BrandModel(name, description); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isEqualTo(description) + ); + } + + @DisplayName("description이 null이어도 생성할 수 있다") + @Test + void createsWithNullDescription() { + // given + BrandName name = new BrandName("아디다스"); + + // when + BrandModel brand = new BrandModel(name, null); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isNull() + ); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("이름과 설명을 변경할 수 있다") + @Test + void updatesNameAndDescription() { + // given + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandName newName = new BrandName("뉴발란스"); + String newDescription = "라이프스타일 브랜드"; + + // when + brand.update(newName, newDescription); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(newName), + () -> assertThat(brand.description()).isEqualTo(newDescription) + ); + } + } +} 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/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..40d981593 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.loopers.domain.brand; + +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/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..6577b447d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,294 @@ +package com.loopers.domain.brand; + +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.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.junit.jupiter.api.Assertions.assertAll; +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 BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("성공하면 저장된 BrandModel을 반환한다") + @Test + void returnsSavedBrand() { + // given + String name = "나이키"; + String description = "스포츠 브랜드"; + when(brandRepository.findByName(name)).thenReturn(Optional.empty()); + when(brandRepository.save(any(BrandModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + BrandModel result = brandService.register(name, description); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description) + ); + verify(brandRepository).save(any(BrandModel.class)); + } + + @DisplayName("중복 브랜드명이면 CONFLICT 예외를 던진다") + @Test + void throwsOnDuplicateName() { + // given + String name = "나이키"; + BrandModel existing = new BrandModel(new BrandName(name), "기존 브랜드"); + when(brandRepository.findByName(name)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register(name, "스포츠 브랜드")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + verify(brandRepository, never()).save(any()); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetBrand { + + @DisplayName("존재하고 미삭제 상태면 BrandModel을 반환한다") + @Test + void returnsBrandWhenExistsAndNotDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrand(brandId); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("삭제된 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 상세 조회 (Admin)") + @Nested + class GetBrandForAdmin { + + @DisplayName("존재하면 삭제 여부와 관계없이 반환한다") + @Test + void returnsBrandRegardlessOfDeletion() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrandForAdmin(brandId); + + // then + assertThat(result.name().value()).isEqualTo("나이키"); + } + + @DisplayName("미존재 브랜드면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrandForAdmin(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @DisplayName("성공하면 변경된 정보를 반환한다") + @Test + void updatesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("뉴발란스")).thenReturn(Optional.empty()); + + // when + BrandModel result = brandService.update(brandId, "뉴발란스", "라이프스타일 브랜드"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), + () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + ); + } + + @DisplayName("동일명 유지 시 중복 체크를 통과한다") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.update(brandId, "나이키", "설명 변경"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("설명 변경") + ); + verify(brandRepository, never()).findByName(any()); + } + + @DisplayName("다른 이름으로 변경 시 중복이면 CONFLICT 예외를 던진다") + @Test + void throwsOnDuplicateNameChange() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandModel other = new BrandModel(new BrandName("아디다스"), "다른 브랜드"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("아디다스")).thenReturn(Optional.of(other)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(brandId, "아디다스", "변경 시도")); + + // then + assertThat(result.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/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/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java index bbd5fdbe1..7a74d1076 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -31,12 +31,12 @@ 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("예시 제목", "예시 설명") @@ -54,11 +54,11 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @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/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/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/MemberAuthServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java new file mode 100644 index 000000000..7700520a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import 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/domain/member/MemberAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java new file mode 100644 index 000000000..42ebd3a1a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java @@ -0,0 +1,98 @@ +package 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/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/MemberPasswordServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java new file mode 100644 index 000000000..f09cb43f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import 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/domain/member/MemberPasswordServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java new file mode 100644 index 000000000..04703f1ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java @@ -0,0 +1,123 @@ +package 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/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/MemberSignupServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java new file mode 100644 index 000000000..8fc4fa9ba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.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/domain/member/MemberSignupServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java new file mode 100644 index 000000000..a2441df86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java @@ -0,0 +1,114 @@ +package 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/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..d0c5ad35d --- /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); + + // 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)); + + // 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))); + + // 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)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..5343c7e77 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,111 @@ +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("음수로 생성하면 BAD_REQUEST 예외를 던진다") + @Test + void throwsOnNegativeValue() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("0으로 생성할 수 있다") + @Test + void createsWithZero() { + // given & when + Money money = new Money(0); + + // then + assertThat(money.value()).isEqualTo(0); + } + + @DisplayName("양수로 생성하면 value()로 값을 반환한다") + @Test + void createsWithPositiveValue() { + // given & when + Money money = new Money(10000); + + // then + assertThat(money.value()).isEqualTo(10000); + } + } + + @DisplayName("Money 연산") + @Nested + class Operations { + + @DisplayName("add()로 두 Money를 합산할 수 있다") + @Test + void addsTwo() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when + Money result = a.add(b); + + // then + assertThat(result.value()).isEqualTo(3000); + } + + @DisplayName("multiply()로 Money에 정수를 곱할 수 있다") + @Test + void multipliesByInt() { + // given + Money money = new Money(1000); + + // when + Money result = money.multiply(3); + + // then + assertThat(result.value()).isEqualTo(3000); + } + } + + @DisplayName("Money 동등성") + @Nested + class Equality { + + @DisplayName("같은 값의 Money는 동등하다") + @Test + void equalsWithSameValue() { + // given + Money a = new Money(1000); + Money b = new Money(1000); + + // when & then + assertAll( + () -> assertThat(a).isEqualTo(b), + () -> assertThat(a.hashCode()).isEqualTo(b.hashCode()) + ); + } + + @DisplayName("다른 값의 Money는 동등하지 않다") + @Test + void notEqualsWithDifferentValue() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when & then + 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..81ca675e8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,132 @@ +package com.loopers.domain.product; + +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; + +class ProductModelTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given + String name = "에어맥스 90"; + String description = "나이키 클래식 러닝화"; + Money price = new Money(129000); + Long brandId = 1L; + + // when + ProductModel product = new ProductModel(name, description, price, brandId); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(name), + () -> assertThat(product.description()).isEqualTo(description), + () -> assertThat(product.price()).isEqualTo(price), + () -> assertThat(product.brandId()).isEqualTo(brandId), + () -> assertThat(product.likeCount()).isEqualTo(0) + ); + } + + @DisplayName("description이 null이어도 생성할 수 있다") + @Test + void createsWithNullDescription() { + // given & when + ProductModel product = new ProductModel("에어맥스 90", null, new Money(129000), 1L); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo("에어맥스 90"), + () -> assertThat(product.description()).isNull() + ); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경할 수 있다") + @Test + void updatesNameDescriptionPrice() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + String newName = "에어맥스 95"; + String newDescription = "뉴 러닝화"; + Money newPrice = new Money(159000); + + // when + product.update(newName, newDescription, newPrice); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(newName), + () -> assertThat(product.description()).isEqualTo(newDescription), + () -> assertThat(product.price()).isEqualTo(newPrice) + ); + } + } + + @DisplayName("likeCount") + @Nested + class LikeCount { + + @DisplayName("생성 시 likeCount는 0이다") + @Test + void defaultsToZero() { + // given & when + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } + + @DisplayName("incrementLikeCount()를 호출하면 likeCount가 1 증가한다") + @Test + void incrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.incrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount()를 호출하면 likeCount가 1 감소한다") + @Test + void decrementsLikeCount() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + product.incrementLikeCount(); + product.incrementLikeCount(); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("likeCount가 0일 때 decrementLikeCount()를 호출하면 0을 유지한다") + @Test + void doesNotGoBelowZero() { + // given + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..74174b1ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,248 @@ +package com.loopers.domain.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.brand.BrandService; +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 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/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..4510387dd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,346 @@ +package com.loopers.domain.product; + +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.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.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("성공하면 저장된 ProductModel을 반환한다") + @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.name()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description), + () -> assertThat(result.price()).isEqualTo(price), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + verify(productRepository).save(any(ProductModel.class)); + } + } + + @DisplayName("상품 조회 (Customer)") + @Nested + class GetProduct { + + @DisplayName("미삭제 상품을 반환한다") + @Test + void returnsProductWhenNotDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProduct(productId); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + } + + @DisplayName("삭제된 상품이면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("미존재 상품이면 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 조회 (Admin)") + @Nested + class GetProductForAdmin { + + @DisplayName("삭제 여부와 관계없이 반환한다") + @Test + void returnsProductRegardlessOfDeletion() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProductForAdmin(productId); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + } + } + + @DisplayName("상품 목록 조회 (Customer)") + @Nested + class GetProducts { + + @DisplayName("brandId 없이 조회하면 미삭제 상품을 반환한다") + @Test + void returnsNotDeletedProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L), + new ProductModel("에어맥스 95", "러닝화", new Money(159000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByDeletedAtIsNull(any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersbyBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandIdAndDeletedAtIsNull(any(Long.class), any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(brandId, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("상품 목록 조회 (Admin)") + @Nested + class GetProductsForAdmin { + + @DisplayName("삭제 포함하여 조회한다") + @Test + void returnsAllProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(null, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @DisplayName("brandId로 필터링하여 조회한다") + @Test + void filtersByBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandId(brandId, pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(brandId, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("상품 수정") + @Nested + class Update { + + @DisplayName("name, description, price를 변경한다") + @Test + void updatesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.update(productId, "에어맥스 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 한다") + @Test + void softDeletesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("에어맥스 90", "러닝화", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + productService.delete(productId); + + // then + 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 DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") + @Test + void softDeletesAllByBrandId() { + // given + Long brandId = 1L; + ProductModel product1 = new ProductModel("에어맥스 90", "러닝화", new Money(129000), brandId); + ProductModel product2 = new ProductModel("에어맥스 95", "러닝화", new Money(159000), brandId); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); + + // when + productService.deleteAllByBrandId(brandId); + + // then + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + } +} 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..fb2f56159 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java @@ -0,0 +1,141 @@ +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("유효한 정보로 생성할 수 있다") + @Test + void createsWithValidInput() { + // given & when + StockModel stock = new StockModel(1L, 100); + + // then + assertAll( + () -> assertThat(stock.productId()).isEqualTo(1L), + () -> assertThat(stock.quantity()).isEqualTo(100) + ); + } + } + + @DisplayName("재고 감소") + @Nested + class Decrease { + + @DisplayName("충분한 수량이면 감소시킨다") + @Test + void decreasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.decrease(30); + + // then + assertThat(stock.quantity()).isEqualTo(70); + } + + @DisplayName("수량을 초과하면 BAD_REQUEST 예외를 던진다") + @Test + void throwsWhenExceedsQuantity() { + // given + StockModel stock = new StockModel(1L, 10); + + // when + CoreException result = assertThrows(CoreException.class, () -> stock.decrease(11)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 증가") + @Nested + class Increase { + + @DisplayName("수량을 증가시킨다") + @Test + void increasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.increase(50); + + // then + assertThat(stock.quantity()).isEqualTo(150); + } + } + + @DisplayName("재고 충분 여부 확인") + @Nested + class HasEnough { + + @DisplayName("수량이 충분하면 true를 반환한다") + @Test + void returnsTrueWhenEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("수량이 부족하면 false를 반환한다") + @Test + void returnsFalseWhenNotEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(11)).isFalse(); + } + } + + @DisplayName("재고 상태 변환") + @Nested + class ToStatus { + + @DisplayName("수량이 11 이상이면 IN_STOCK을 반환한다") + @Test + void returnsInStock() { + // given + StockModel stock = new StockModel(1L, 11); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.IN_STOCK); + } + + @DisplayName("수량이 1~10이면 LOW_STOCK을 반환한다") + @Test + void returnsLowStock() { + // given + StockModel stock = new StockModel(1L, 5); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.LOW_STOCK); + } + + @DisplayName("수량이 0이면 OUT_OF_STOCK을 반환한다") + @Test + void returnsOutOfStock() { + // given + StockModel stock = new StockModel(1L, 0); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.OUT_OF_STOCK); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java new file mode 100644 index 000000000..b320819d7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -0,0 +1,125 @@ +package com.loopers.domain.stock; + +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 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; + +@ExtendWith(MockitoExtension.class) +class StockServiceTest { + + @Mock + private StockRepository stockRepository; + + private StockService stockService; + + @BeforeEach + void setUp() { + stockService = new StockService(stockRepository); + } + + @DisplayName("재고 생성") + @Nested + class Create { + + @DisplayName("productId와 quantity로 재고를 생성한다") + @Test + void createsStock() { + // given + Long productId = 1L; + int quantity = 100; + when(stockRepository.save(any(StockModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + StockModel result = stockService.create(productId, quantity); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(quantity) + ); + verify(stockRepository).save(any(StockModel.class)); + } + } + + @DisplayName("상품별 재고 조회") + @Nested + class GetByProductId { + + @DisplayName("존재하면 재고를 반환한다") + @Test + void returnsStockWhenExists() { + // given + Long productId = 1L; + StockModel stock = new StockModel(productId, 100); + when(stockRepository.findByProductId(productId)).thenReturn(Optional.of(stock)); + + // when + StockModel result = stockService.getByProductId(productId); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(100) + ); + } + + @DisplayName("미존재 시 NOT_FOUND 예외를 던진다") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(stockRepository.findByProductId(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> stockService.getByProductId(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @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/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/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..40c0f8165 --- /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), 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))), 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)))), + 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, null, 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, null, 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..59d3c8ba6 --- /dev/null +++ b/docs/blog/ddd-responsibility-separation.md @@ -0,0 +1,361 @@ + +## 들어가며 + +[이전 글](https://velog.io/@praesentia-ykm)에서 33개의 Q&A로 설계를 먼저 한 이야기를 했다. 이번에는 그 설계 과정에서 가장 머리를 싸맸던 부분 — **"이 코드를 어디에 둬야 하는가?"** — 에 대해 써보려 한다. + +DDD(Domain-Driven Design)를 공부하면 "바운디드 컨텍스트", "어그리게이트", "도메인 서비스" 같은 용어가 쏟아진다. 개념 자체는 어렵지 않다. 문제는 **실제 코드에 적용하려 할 때** 발생한다. + +> "ProductService에 있어야 해, 아니면 ProductFacade에 있어야 해?" +> "Stock이랑 Product를 한 테이블에 두면 안 돼?" +> "LikeFacade가 ProductService를 직접 부르는 게 맞아?" + +이런 질문에 "상황에 따라 다릅니다"는 답이 되지 않는다. **어떤 상황에서 어떻게 달라지는지**, 그 갈림길의 기준을 찾고 싶었다. + +--- + +## 내가 처음 그린 설계 흐름, 그리고 수정 + +DDD를 나름대로 공부하고 처음 머릿속에 그린 설계 흐름은 이랬다. + +``` +1. 최상위 도메인 구분 +2. 유비쿼터스 언어 구분 +3. 바운디드 컨텍스트 구분 +4. 루트 어그리게이트 +5. 레이어 구분 +6. 도메인 서비스 / 비즈니스 서비스 +``` + +뭔가 그럴듯해 보인다. 그런데 이 흐름대로 설계를 해보니 세 군데서 걸렸다. + +### 걸림돌 1 — 유비쿼터스 언어와 바운디드 컨텍스트는 분리할 수 없다 + +2번에서 "유비쿼터스 언어를 정리"하고, 3번에서 "바운디드 컨텍스트를 나누자"라고 했는데 — 이 두 개는 순차적으로 할 수 있는 일이 아니었다. + +"상품"이라는 단어를 정의하려는 순간, **카탈로그 팀에서의 "상품"과 주문 팀에서의 "상품"이 다르다**는 걸 발견한다. 이 발견 자체가 곧 경계를 긋는 행위다. + +비유를 들자면, 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것이다. "여기서부터 이 언어가 통하지 않는다"를 발견하는 순간이 곧 국경선이 그어지는 순간이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, **언어 차이가 국경을 드러낸다.** + +### 걸림돌 2 — 컨텍스트 매핑이 빠져 있었다 + +바운디드 컨텍스트를 나눈 건 좋은데, **"나눈 것들이 서로 어떻게 대화하는가?"**를 정의하는 단계가 없었다. 카탈로그와 주문이 분리되었으면 주문할 때 상품 정보를 어떻게 가져올지, 좋아요 수를 어떻게 갱신할지 — 이 통신 방식을 결정하는 게 컨텍스트 매핑이다. + +이걸 빼먹으면 "잘 나눈 것 같은데 결국 다 얽혀있네?"라는 상황이 된다. + +### 걸림돌 3 — "비즈니스 서비스"라는 건 없다 + +마지막에 "도메인 서비스 / 비즈니스 서비스"라고 적었는데, DDD에는 "비즈니스 서비스"라는 용어가 없다. 내가 "비즈니스 서비스"라고 부르고 있던 것은 사실 **애플리케이션 서비스**였다. + +이름이 중요한 게 아니라고 생각할 수 있지만, 이름이 모호하면 기준도 모호해진다. "비즈니스 서비스에 뭘 넣지?"라는 질문에는 "비즈니스 로직?"이라는 동어반복밖에 나오지 않는다. "애플리케이션 서비스에 뭘 넣지?"라는 질문에는 "유스케이스 절차"라는 명확한 답이 나온다. + +### 수정된 흐름 + +세 가지를 고치면 이렇게 된다. + +``` +수정 전 수정 후 +───────────────── ───────────────── +1. 최상위 도메인 구분 → 1. 서브도메인 식별 + 분류 (Core/Supporting/Generic) +2. 유비쿼터스 언어 구분 ┐ 2. 유비쿼터스 언어 ↔ 바운디드 컨텍스트 (동시 발견) +3. 바운디드 컨텍스트 구분 ┘ + (빠짐) → 3. 컨텍스트 매핑 (관계 정의) +4. 루트 어그리게이트 → 4. 전술적 설계 (어그리게이트 + 엔티티/VO) +5. 레이어 구분 → 5. 레이어드 아키텍처 +6. 도메인 서비스/비즈니스 서비스 → 6. 도메인 서비스 vs 애플리케이션 서비스 (용어 정정) +``` + +방향 자체가 틀렸던 건 아니다. 다만 2번과 3번이 하나의 동시 과정이라는 것, 컨텍스트 매핑이 빠져 있었다는 것, 용어가 부정확했다는 것 — 이 세 가지를 인식하니 설계가 더 선명해졌다. + +--- + +## "상품이 뭔데?" — 바운디드 컨텍스트의 경계를 찾는 법 + +위에서 "언어 차이가 곧 경계"라고 했다. 그 과정을 실제 코드로 보여주겠다. + +현재 `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, 주문시점가격, 수량 | 현재가격, 재고, 좋아요 | + +같은 "상품"인데 **필요한 속성이 완전히 다르다.** 이 차이가 바운디드 컨텍스트의 경계다. + +### 기준을 한 문장으로 요약하면 + +> **같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계** + +단, 이 기준은 **같은 도메인 용어가 여러 맥락에서 쓰일 때만** 적용된다. 애초에 다른 단어를 쓰는 영역(예: "상품"과 "결제수단")은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다. + +### 무의식적으로 이미 적용하고 있었다 + +Q&A 브레인스토밍을 하면서 나도 모르게 이 경계를 지키고 있었다. + +- `LikeModel`은 `ProductModel`을 직접 참조하지 않고 `productId`만 보유한다. +- `StockModel`도 `productId`만 보유한다. +- `OrderItemModel`에는 주문 시점의 `productName`, `productPrice`를 **스냅샷**으로 복사한다. + +즉, 각 도메인은 "상품" 전체를 알 필요 없이 자기에게 필요한 단편만 들고 있다. 이것이 바로 바운디드 컨텍스트 간의 **느슨한 참조(ID 참조)**이고, 의식하든 안 하든 자연스럽게 흘러가는 설계의 방향이었다. + +--- + +## 서브도메인 분류 — "어디에 시간을 쓸 것인가" + +경계를 나눈 다음에는 "이 중에 뭐가 제일 중요한가?"를 판단해야 한다. DDD에서는 이걸 서브도메인 분류라고 부른다. + +| 유형 | 의미 | 설계 전략 | +|------|------|-----------| +| **Core** | 비즈니스 경쟁력의 핵심 | 직접 설계하고 정교하게 구현 | +| **Supporting** | Core를 보조. 중요하지만 차별화 요소는 아님 | 직접 구현하되 Core만큼의 투자는 불필요 | +| **Generic** | 어디서나 비슷하게 필요한 범용 기능 | 외부 솔루션 사용 가능 | + +이커머스에서 이걸 적용하면: + +| 서브도메인 | 유형 | 왜? | +|-----------|------|-----| +| **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | +| **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 그 자체가 경쟁력은 아님 | +| **좋아요** | Supporting | 고객 선호 추적. 카탈로그의 인기순 정렬에 활용 | +| **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | + +이 분류가 코드에 주는 영향은 명확하다. 카탈로그와 주문은 도메인 모델을 정교하게 설계하고, 회원/인증은 `userId`만 받아서 참조한다. 실제로 현재 코드에서 `MemberFacade`는 단순한 CRUD뿐이고, `OrderFacade`는 재고 차감, 스냅샷 생성, All or Nothing 검증까지 복잡한 규칙이 들어있다. + +--- + +## 브랜드와 상품이 같은 BC인 이유 — 트랜잭션이 답했다 + +바운디드 컨텍스트를 나누다 보면 "이 둘은 같은 데 넣어야 하나, 따로 빼야 하나?"라는 판단이 필요하다. Brand와 Product가 그랬다. + +직감적으로는 분리하고 싶었다. 브랜드는 브랜드고 상품은 상품이니까. 그런데 Q&A 과정에서 이런 질문을 던졌었다. + +> **Q1: 브랜드를 삭제하면 소속 상품은 어떻게 되는가?** + +답은 "브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete"였고, 이것은 **하나의 트랜잭션**으로 처리되어야 했다. + +```java +// BrandFacade — 하나의 트랜잭션 안에서 브랜드 + 소속 상품 전체 삭제 +@Transactional +public void deleteBrand(Long brandId) { + brandService.delete(brandId); // 브랜드 soft delete + productService.softDeleteByBrandId(brandId); // 소속 상품 전체 soft delete +} +``` + +만약 Brand와 Product가 다른 바운디드 컨텍스트에 있다면 이 트랜잭션은 **분산 트랜잭션**이 된다. 모놀리스에서는 문제없지만, 시스템을 분리하는 순간 Saga 패턴 같은 복잡한 메커니즘이 필요해진다. "브랜드 삭제"라는 단순한 요구사항에 그 복잡도는 과하다. + +**결론:** 하나의 트랜잭션으로 처리되어야 하는 연산이 있으면, 같은 바운디드 컨텍스트에 둔다. + +--- + +## 어그리게이트 분리 — Product와 Stock은 왜 따로인가 + +같은 바운디드 컨텍스트 안에서도 "어디까지를 하나의 단위로 묶을 것인가"를 결정해야 한다. 이게 어그리게이트 경계다. + +Product와 Stock은 1:1 관계인데, 왜 하나로 합치지 않았을까? + +[이전 글](https://velog.io/@praesentia-ykm)의 트레이드오프 분석에서 이미 결정한 내용이지만 DDD 관점에서 다시 한번 증명해보면, 핵심 기준은 **"같이 잠글 필요가 있는가?"**다. + +| 변경 시나리오 | Product 변경? | Stock 변경? | 결론 | +|-------------|:----------:|:----------:|------| +| 상품명 수정 | O | X | 독립 | +| 가격 수정 | O | X | 독립 | +| 재고 차감 (주문) | X | O | 독립 | +| 상품 등록 (초기 재고 포함) | O | O | Facade에서 조율 | + +4개 시나리오 중 3개가 독립적이다. 상품 정보를 수정할 때 재고를 잠글 필요가 없고, 재고를 차감할 때 상품 정보를 잠글 필요가 없다. + +유일하게 둘 다 변경되는 "상품 등록"은 Facade에서 조율한다. 이건 **비즈니스 규칙이 아니라 절차**다. "상품을 등록하면서 초기 재고도 만든다"는 순서의 문제이지, 둘이 반드시 원자적으로 잠겨야 하는 건 아니다. + +```java +// ProductFacade — 절차를 조율 +@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에서 조율.** + +--- + +## 도메인 서비스 vs 애플리케이션 서비스 — 가장 많이 헷갈린 구분 + +설계하면서 가장 오래 고민한 건 이거다. **"이 로직을 `domain/XxxService`에 둘까, `application/XxxFacade`에 둘까?"** + +### "규칙"인가, "절차"인가 + +결론부터 말하면, 판별 기준은 하나다. + +> **규칙이면 도메인 서비스. 절차면 애플리케이션 서비스.** + +이것만으로는 부족하다. "규칙"과 "절차"가 뭔지를 구분할 수 있어야 한다. + +**규칙(Rule):** "같은 이름의 브랜드는 등록할 수 없다." 이건 사업부에서 정한 비즈니스 제약이다. 이 규칙을 빼면 시스템의 일관성이 깨진다. + +```java +// BrandService (도메인 서비스) — 규칙을 담는다 +public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← 비즈니스 규칙 + }); + return brandRepository.save(new BrandModel(name, description)); +} +``` + +**절차(Procedure):** "상품을 등록할 때는 브랜드 존재를 확인하고, 상품을 만들고, 재고를 만든다." 이건 순서다. 각 단계의 규칙은 각자의 도메인 서비스가 갖고 있고, Facade는 "어떤 순서로 호출할지"만 안다. + +```java +// ProductFacade (애플리케이션 서비스) — 절차를 조율한다 +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 위임 + ProductModel product = productService.register(...); // 2. 위임 + stockService.create(product.getId(), initialStock); // 3. 위임 + return product; // ← 자체 규칙 없음 +} +``` + +### 구분의 리트머스 테스트 + +더 실용적인 판별법 네 가지. + +| 질문 | 도메인 서비스 | 애플리케이션 서비스 | +|------|-------------|-------------------| +| 자체 비즈니스 규칙이 있는가? | **있다** (유니크 검증, 상태 전이, 음수 방지) | **없다** (위임만 수행) | +| 다른 Service를 조합하는가? | 같은 도메인 내 객체만 다룬다 | **여러 도메인 Service를 조합**한다 | +| `@Transactional` 경계인가? | 아닐 수 있다 | **맞다** (유스케이스 단위) | +| 이걸 제거하면? | **비즈니스 규칙이 깨진다** | 절차가 사라질 뿐, 규칙은 유지된다 | + +4번이 가장 강력한 리트머스다. `BrandService`를 제거하면 "같은 이름 브랜드 방지" 규칙이 사라진다. `ProductFacade`를 제거하면? 각 도메인 서비스의 규칙은 그대로 남아있고, "이 순서로 호출하는" 편의만 사라진다. + +### 판별 범위 한정 + +이 "규칙 vs 절차" 구분은 **도메인 계층과 애플리케이션 계층 사이에서만** 유효하다. Controller(HTTP 요청/응답 변환)나 Repository(데이터 저장소 접근)에는 적용하지 않는다. Controller에 "규칙이 있느냐"고 물으면 "없다"인데, 그렇다고 Controller가 애플리케이션 서비스인 건 아니다. + +--- + +## 컨텍스트 매핑 — 나눈 것들이 대화하는 방법 + +바운디드 컨텍스트를 나눠 놓고 끝이 아니다. **"얘네가 서로 어떻게 대화하는가?"**를 정해야 한다. + +현재 프로젝트의 의존 관계를 그려보면: + +``` + ┌─────────────┐ + │ 회원/인증 BC │ + │ (Generic) │ + └──────┬──────┘ + │ userId + ┌────────┼────────┐ + ▼ ▼ │ + ┌──────────┐ ┌────────┐ │ + │좋아요 BC │ │주문 BC │ │ + │(Support) │ │(Core) │ │ + └────┬─────┘ └───┬────┘ │ + │ │ │ + likeCount│ 재고차감 │ │ + 갱신 │ ▼ │ + │ ┌──────────┐ │ + │ │ 재고 BC │ │ + │ │(Support) │ │ + │ └────┬─────┘ │ + │ │ │ + ▼ productId │ + ┌───────────────┴────────┘ + │ 카탈로그 BC (Core) + │ Brand ── Product + └──────────────────────── +``` + +모놀리스에서는 Facade가 다른 도메인의 Service를 직접 호출한다. + +| 호출 | 방식 | 시스템 분리 시 전환 | +|------|------|-------------------| +| `ProductFacade` → `BrandService` | 직접 호출 | 같은 BC, 분리 불필요 | +| `ProductFacade` → `StockService` | 직접 호출 | API 호출 또는 이벤트 | +| `LikeFacade` → `ProductService` | 직접 호출 | **도메인 이벤트** | +| `OrderFacade` → `ProductService` + `StockService` | 직접 호출 | Saga 패턴 | + +여기서 한 가지 설계적 주의 지점이 있다. + +```java +// LikeFacade — 좋아요 BC가 카탈로그 BC의 엔티티를 직접 수정한다 +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + // ... 좋아요 로직 + product.incrementLikeCount(); // ← 다른 BC의 엔티티를 직접 변경 +} +``` + +모놀리스에서는 이게 실용적이다. 하지만 이 코드가 **BC 경계를 넘는 직접 수정**이라는 사실은 인식하고 있어야 한다. 시스템이 커져서 물리적으로 분리할 때, 이 부분은 도메인 이벤트로 전환해야 한다. + +> 좋아요 발생 → `LikeCreatedEvent` 발행 → 카탈로그 BC가 수신 → `likeCount` 갱신 + +"지금은 직접 호출하되, 여기가 나중에 잘라야 할 지점"이라는 걸 아는 것과 모르는 것은 다르다. 컨텍스트 매핑의 가치가 여기에 있다. + +--- + +## 현재 프로젝트의 전체 배치 + +지금까지의 판별 기준을 적용한 결과물이다. + +### 도메인 서비스 (규칙) + +| 컴포넌트 | 담당 규칙 | +|---------|---------| +| `BrandService` | 브랜드명 유니크 검증, CRUD | +| `ProductService` | 상품 CRUD, likeCount 증감, soft delete | +| `StockService` | 재고 생성, `checkAndDecrease` (음수 방지) | +| `LikeService` | 좋아요 등록/취소, 멱등성, 존재 여부 조회 | +| `OrderService` | 주문 생성, 총액 계산 | + +### 애플리케이션 서비스 (절차) + +| 컴포넌트 | 조율하는 절차 | +|---------|------------| +| `BrandFacade` | 브랜드 삭제 → 소속 상품 연쇄 soft delete | +| `ProductFacade` | 브랜드 존재 확인 → 상품 생성 → 재고 생성 | +| `LikeFacade` | 삭제된 상품 체크 → 좋아요 처리 → likeCount 동기화 | +| `OrderFacade` | 상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing) | + +모든 Facade에는 공통점이 있다. **자체 규칙이 없고, 여러 도메인 서비스를 순서대로 호출**한다. 각 단계의 비즈니스 규칙은 해당 도메인 서비스가 갖고 있다. + +--- + +## 회고 — 나만의 판별 기준 세 줄 + +DDD 책을 읽으면 "컨텍스트 경계를 잘 나누세요", "도메인 서비스에 비즈니스 로직을 두세요"라고 한다. 맞는 말인데, **"잘"이 뭔데?** + +이번 프로젝트를 거치며 내가 내린 판별 기준은 세 가지다. + +**1. 바운디드 컨텍스트 경계:** +> 같은 단어가 다른 속성/행위를 요구하면 경계다. 하나의 트랜잭션으로 묶여야 하면 같은 BC다. + +**2. 어그리게이트 분리:** +> 같이 잠글 필요가 없으면 별도 어그리게이트. 동시 변경은 Facade에서 조율. + +**3. 도메인 서비스 vs 애플리케이션 서비스:** +> 제거했을 때 비즈니스 규칙이 깨지면 도메인 서비스. 절차만 사라지면 애플리케이션 서비스. + +이 세 줄이 모든 경우를 커버하진 않는다. 하지만 "어디에 뭘 둬야 하지?"라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, **기준을 들이대고 검증할 수 있다**는 점에서 설계의 질이 달라졌다. + + 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..9fe9a26fa --- /dev/null +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -0,0 +1,337 @@ +# 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) +domain/ → 비즈니스 규칙 (Entity, VO, DomainService) +infrastructure/ → 기술 구현 (JpaRepository) +``` + +### 2-6. 도메인 서비스 vs 애플리케이션 서비스 + +> "비즈니스 서비스"는 DDD 용어가 아니다. 구분하려는 것은 아래 두 가지다. + +| | 도메인 서비스 | 애플리케이션 서비스 | +|---|---|---| +| **현재 프로젝트** | `domain/XxxService` | `application/XxxFacade` | +| **담는 것** | 비즈니스 **규칙** | 유스케이스 **절차** | +| **판별 질문** | "이 로직이 특정 엔티티 하나의 책임인가?" → No → 도메인 서비스 | "이것은 규칙인가, 절차인가?" → 절차 → 애플리케이션 서비스 | + +**적용 범위:** 이 "규칙 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 -- "규칙" --> E["도메인 서비스
domain/XxxService"] + D -- "절차" --> F["애플리케이션 서비스
application/XxxFacade"] + + style C fill:#e8f5e9 + style E fill:#e3f2fd + style F fill:#fff3e0 +``` + +### 코드로 보는 구분 + +**도메인 서비스 — 규칙을 담는다:** + +```java +// BrandService: "같은 이름의 브랜드는 등록할 수 없다" +public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // ← 비즈니스 규칙 + }); + return brandRepository.save(new BrandModel(name, description)); +} +``` + +**애플리케이션 서비스(Facade) — 절차를 조율한다:** + +```java +// ProductFacade: "상품 등록 시 브랜드 확인 → 상품 생성 → 재고 생성" +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. 브랜드 존재 확인 (위임) + ProductModel product = productService.register(...); // 2. 상품 생성 (위임) + stockService.create(product.getId(), initialStock); // 3. 재고 생성 (위임) + return product; // ← 자체 규칙 없음, 절차만 있음 +} +``` + +### 판별 기준 요약표 + +| 질문 | 도메인 서비스 | 애플리케이션 서비스 | +|------|-------------|-------------------| +| 자체 비즈니스 규칙이 있는가? | **있다** (유니크 검증, 상태 전이) | **없다** (위임만 수행) | +| 다른 Service를 조합하는가? | 같은 도메인 내 객체만 | **여러 도메인 Service를 조합** | +| `@Transactional` 경계인가? | 아닐 수 있음 | **맞다** (유스케이스 단위) | +| 제거하면 비즈니스 규칙이 깨지는가? | **깨진다** | 절차가 사라질 뿐, 규칙은 유지됨 | + +### 현재 프로젝트의 배치 + +| 컴포넌트 | 계층 | 역할 | +|---------|------|------| +| `BrandService` | domain | 브랜드명 유니크 검증, CRUD | +| `ProductService` | domain | 상품 CRUD, likeCount 증감 | +| `StockService` | domain | 재고 생성, 차감(`checkAndDecrease`) | +| `LikeService` | domain | 좋아요 등록/취소, 존재 여부 조회 | +| `BrandFacade` | application | 삭제 시 소속 상품 연쇄 soft delete | +| `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | +| `LikeFacade` | application | 삭제된 상품 체크, likeCount 동기화 | + +--- + +## 부록: 어그리게이트 분리 판단 — 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..dbf61bc92 --- /dev/null +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -0,0 +1,95 @@ +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 LikeFacade { + 멱등성 + likeCount 동기화 + } + } + + 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 --> BrandModel + ProductFacade --> ProductModel + ProductFacade --> StockModel + LikeFacade --> LikeModel + OrderFacade --> OrderModel + OrderFacade --> OrderItemModel + + BrandFacade --> ProductModel + LikeFacade --> ProductModel + OrderFacade --> StockModel \ 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/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-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-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..0647ce3e3 --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,45 @@ +### [Customer] 상품 목록 조회 +GET {{commerce-api}}/api/v1/products?page=0&size=10 + +### [Customer] 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api/v1/products?brandId=1&sort=LATEST&page=0&size=10 + +### [Customer] 상품 목록 조회 (가격 오름차순) +GET {{commerce-api}}/api/v1/products?sort=PRICE_ASC&page=0&size=10 + +### [Customer] 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 + +### [Admin] 상품 등록 +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json + +{ + "name": "에어맥스 90", + "description": "나이키 클래식 러닝화", + "price": 129000, + "brandId": 1, + "initialStock": 100 +} + +### [Admin] 상품 목록 조회 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=10 + +### [Admin] 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=10 + +### [Admin] 상품 상세 조회 +GET {{commerce-api}}/api-admin/v1/products/1 + +### [Admin] 상품 수정 +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json + +{ + "name": "에어맥스 95", + "description": "뉴 러닝화", + "price": 159000 +} + +### [Admin] 상품 삭제 +DELETE {{commerce-api}}/api-admin/v1/products/1