diff --git a/.claude/hooks/tdd-notion-logger.py b/.claude/hooks/tdd-notion-logger.py new file mode 100755 index 00000000..04ffbfcb --- /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 00000000..61fadfa1 --- /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 00000000..5d525720 --- /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 a8ace53e..00000000 --- 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 5a979af6..f9bd5007 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 00000000..d547a18a --- /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 00000000..c426b1fc --- /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 03ce68f0..9ad4d8ea 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/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..75658e7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,36 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberAuthService; +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 MemberAuthService memberAuthService; + private final MemberPasswordService memberPasswordService; + + public MemberInfo signup(String loginId, String password, String name, + LocalDate birthDate, String email) { + MemberModel member = memberSignupService.signup(loginId, password, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberAuthService.authenticate(loginId, password); + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(String loginId, String password, + String currentPassword, String newPassword) { + MemberModel member = memberAuthService.authenticate(loginId, password); + 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 00000000..c20d1c9d --- /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/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..d42feb17 --- /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/domain/member/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java new file mode 100644 index 00000000..948d99b1 --- /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 00000000..0f7da8a3 --- /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 00000000..f90f86e5 --- /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 00000000..e0ffb37c --- /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 00000000..7c850b27 --- /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 00000000..76e9d93f --- /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 00000000..8ed51f87 --- /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 00000000..89505937 --- /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 00000000..dbb4ab6d --- /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/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..2eb5cfe8 --- /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 00000000..5f7f6e00 --- /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/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 00000000..9cca8fd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +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(MemberV1Dto.SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "헤더 인증을 통해 내 정보를 조회합니다.") + ApiResponse getMe(String loginId, String password); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword(String loginId, String password, + MemberV1Dto.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 00000000..a3ee3eb2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +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( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, password, + 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 00000000..9ae0821d --- /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/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..2c55138e 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/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 00000000..75353325 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,110 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeTest { + + @Mock + private MemberSignupService memberSignupService; + + @Mock + private MemberAuthService memberAuthService; + + @Mock + private MemberPasswordService memberPasswordService; + + private MemberFacade memberFacade; + + @BeforeEach + void setUp() { + memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @DisplayName("SignupService에 위임하고 MemberInfo를 반환한다") + @Test + void delegatesToSignupService() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberSignupService.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")).thenReturn(member); + + // when + MemberInfo result = memberFacade.signup("kwonmo", "Test1234!", "양권모", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("kwonmo"), + () -> assertThat(result.name()).isEqualTo("양권모"), + () -> assertThat(result.email()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("내 정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("인증 후 마스킹된 이름으로 반환한다") + @Test + void returnsWithMaskedName() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("양권모"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + when(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); + + // when + MemberInfo result = memberFacade.getMyInfo("kwonmo", "Test1234!"); + + // 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(memberAuthService.authenticate("kwonmo", "Test1234!")).thenReturn(member); + + // when + memberFacade.changePassword("kwonmo", "Test1234!", "Current1!", "NewPass5678!"); + + // then + verify(memberPasswordService).changePassword(member, "Current1!", "NewPass5678!"); + } + } +} 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 44ca7576..5a94cb89 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 bbd5fdbe..7a74d107 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/member/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java new file mode 100644 index 00000000..6030b304 --- /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 00000000..98ca4149 --- /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 00000000..7700520a --- /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 00000000..42ebd3a1 --- /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 00000000..ff9a0e1b --- /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 00000000..d72086b0 --- /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 00000000..f09cb43f --- /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 00000000..04703f1e --- /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 00000000..02c2fea8 --- /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 00000000..8fc4fa9b --- /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 00000000..a2441df8 --- /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 00000000..8caedef1 --- /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/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java index 1bb3dba6..bee78db5 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 00000000..b79abda3 --- /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/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/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/members") + @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/members/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("PATCH /api/v1/members/me/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.PATCH, + 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.PATCH, + 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.PATCH, + 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/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e..aff2274c 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/docs/member-implementation-plan.md b/docs/member-implementation-plan.md new file mode 100644 index 00000000..0ace6419 --- /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 실행 +```