From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 1/9] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md 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` 응닡을 λ°˜ν™˜ν•œλ‹€. From c7b0383e6b3eceeae249727cfa92bccc0f9766e8 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:11:14 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore:=20Claude=20Code=20=EC=84=B8=ED=8C=85?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .claude/hooks/tdd-notion-logger.py | 470 +++++++++++++++++++++++++++++ .claude/settings.json | 16 + .claude/settings.local.json | 27 ++ .gitignore | 42 +-- CLAUDE.md | 152 ++++++++++ docs/member-implementation-plan.md | 455 ++++++++++++++++++++++++++++ 6 files changed, 1123 insertions(+), 39 deletions(-) create mode 100755 .claude/hooks/tdd-notion-logger.py create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 docs/member-implementation-plan.md 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/.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/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 μ‹€ν–‰ +``` From 95da7027b8497f5a01189abb71c71c815fa108b7 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:08:24 +0900 Subject: [PATCH 3/9] =?UTF-8?q?chore:=20Member=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EA=B8=B0=EB=B0=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spring-security-crypto μ˜μ‘΄μ„± μΆ”κ°€ (BCryptPasswordEncoder) - PasswordEncoderConfig 빈 등둝 - ErrorType에 UNAUTHORIZED μΆ”κ°€ Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 3 +++ .../com/loopers/config/PasswordEncoderConfig.java | 15 +++++++++++++++ .../java/com/loopers/support/error/ErrorType.java | 1 + 3 files changed, 19 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java 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/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/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..8d493491 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,6 +10,7 @@ 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(), "이미 μ‘΄μž¬ν•˜λŠ” λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€."); From 0335bc1789e95f67d8172dbdfe1bbe9824abda61 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:16:49 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20Value=20Object=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4개 VO κ΅¬ν˜„ (LoginId, Password, MemberName, Email) - MemberModel μ—”ν‹°ν‹° (@Embedded VO, matchesPassword ν–‰μœ„ λ©”μ„œλ“œ) - MemberRepository μΈν„°νŽ˜μ΄μŠ€ 및 JPA κ΅¬ν˜„ - ErrorType 도메인 μ—λŸ¬ μ½”λ“œ μΆ”κ°€ (10개) - λ‹¨μœ„ ν…ŒμŠ€νŠΈ: VO 검증 + MemberModel + Repository ν†΅ν•©ν…ŒμŠ€νŠΈ Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/member/Email.java | 44 +++++ .../com/loopers/domain/member/LoginId.java | 44 +++++ .../loopers/domain/member/MemberModel.java | 69 +++++++ .../com/loopers/domain/member/MemberName.java | 50 +++++ .../domain/member/MemberRepository.java | 10 + .../com/loopers/domain/member/Password.java | 52 ++++++ .../member/MemberJpaRepository.java | 11 ++ .../member/MemberRepositoryImpl.java | 25 +++ .../com/loopers/support/error/ErrorType.java | 14 +- .../com/loopers/domain/member/EmailTest.java | 72 ++++++++ .../loopers/domain/member/LoginIdTest.java | 83 +++++++++ .../domain/member/MemberModelTest.java | 92 ++++++++++ .../loopers/domain/member/MemberNameTest.java | 94 ++++++++++ .../domain/member/MemberRepositoryTest.java | 90 +++++++++ .../loopers/domain/member/PasswordTest.java | 173 ++++++++++++++++++ 15 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java 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/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/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/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/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491..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 @@ -12,7 +12,19 @@ public enum ErrorType { 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/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/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/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/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)); + } + } +} From 3a9f4184540cd67d81ffdf0964ec1730263e8939 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:17:09 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberSignupService (쀑볡 체크, λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™”, μ €μž₯) - λ‹¨μœ„ ν…ŒμŠ€νŠΈ: Mock을 ν™œμš©ν•œ λ™μž‘ 검증 - 톡합 ν…ŒμŠ€νŠΈ: μ‹€μ œ DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberSignupService.java | 35 ++++++ .../MemberSignupServiceIntegrationTest.java | 117 ++++++++++++++++++ .../member/MemberSignupServiceTest.java | 114 +++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java 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/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()); + } + } +} From 337aba516ea3ddeb4c8a8931bee9bc4d9fddc6c6 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:08 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberAuthService (loginId/password 검증, νšŒμ› 쑰회) - λ‹¨μœ„ ν…ŒμŠ€νŠΈ: Mock을 ν™œμš©ν•œ λ™μž‘ 검증 - 톡합 ν…ŒμŠ€νŠΈ: μ‹€μ œ DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberAuthService.java | 27 +++++ .../MemberAuthServiceIntegrationTest.java | 79 +++++++++++++++ .../domain/member/MemberAuthServiceTest.java | 98 +++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java 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/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); + } + } +} From d1159edf5fe5339cea4b198eb5de345e2e0a50bc Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:23 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberPasswordService (ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 검증, μƒˆ λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” μ €μž₯) - λ‹¨μœ„ ν…ŒμŠ€νŠΈ: Mock을 ν™œμš©ν•œ λ™μž‘ 검증 - 톡합 ν…ŒμŠ€νŠΈ: μ‹€μ œ DB 연동 검증 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberPasswordService.java | 33 +++++ .../MemberPasswordServiceIntegrationTest.java | 117 +++++++++++++++++ .../member/MemberPasswordServiceTest.java | 123 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java 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/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()); + } + } +} From e3aa8944c61a2eef385463d1da334243df346d8e Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20Member=20API=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberFacade (signup, getMyInfo, changePassword) - MemberInfo 응닡 DTO (이름 λ§ˆμŠ€ν‚Ή 포함) - MemberV1Controller (POST /members, GET /me, PATCH /me/password) - MemberV1Dto (SignupRequest, MemberResponse, ChangePasswordRequest) - E2E ν…ŒμŠ€νŠΈ: MemberV1ApiE2ETest - MemberFacadeTest λ‹¨μœ„ ν…ŒμŠ€νŠΈ Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 36 +++ .../application/member/MemberInfo.java | 26 ++ .../api/member/MemberV1ApiSpec.java | 19 ++ .../api/member/MemberV1Controller.java | 55 ++++ .../interfaces/api/member/MemberV1Dto.java | 32 +++ .../application/member/MemberFacadeTest.java | 110 ++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 253 ++++++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java 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/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/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/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); + } + } +} From 3156d07f57c4270bc90c49b15862d5e1a720ae8d Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:19:12 +0900 Subject: [PATCH 9/9] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Example/Core ν…ŒμŠ€νŠΈ DisplayName μžμ—°μŠ€λŸ½κ²Œ κ°œμ„  - TEST-README.md ν…ŒμŠ€νŠΈ 체크리슀트 μΆ”κ°€ Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/TEST-README.md | 150 ++++++++++++++++++ .../domain/example/ExampleModelTest.java | 14 +- .../ExampleServiceIntegrationTest.java | 12 +- .../interfaces/api/ExampleV1ApiE2ETest.java | 6 +- .../support/error/CoreExceptionTest.java | 8 +- 5 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 apps/commerce-api/TEST-README.md 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/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/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/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";