From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/19] 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 a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. From c7b0383e6b3eceeae249727cfa92bccc0f9766e8 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:11:14 +0900 Subject: [PATCH 02/19] =?UTF-8?q?chore:=20Claude=20Code=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=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 000000000..04ffbfcb4 --- /dev/null +++ b/.claude/hooks/tdd-notion-logger.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +""" +TDD Notion Logger Hook for Claude Code. + +PostToolUse hook that logs TDD Red-Green-Refactor cycles to a Notion page +with AI reasoning extracted from the conversation transcript. +""" + +import json +import os +import re +import sys +import urllib.request +import urllib.error +from datetime import datetime, timezone, timedelta + +NOTION_PAGE_ID = "2fc2e1bd53b2809cbd5ed9009dc775bd" +NOTION_API_VERSION = "2022-06-28" +KST = timezone(timedelta(hours=9)) + +TEST_FILE_PATTERN = re.compile(r".*Test\.java$") +JAVA_FILE_PATTERN = re.compile(r".*\.java$") + + +def main(): + hook_input = json.loads(sys.stdin.read()) + + tool_name = hook_input.get("tool_name", "") + if tool_name != "Bash": + return + + command = hook_input.get("tool_input", {}).get("command", "") + if not re.search(r"gradlew.*test", command): + return + + tool_response = hook_input.get("tool_response", {}) + stdout = extract_stdout(tool_response) + + if "BUILD SUCCESSFUL" not in stdout: + return + + notion_api_key = os.environ.get("NOTION_API_KEY") + if not notion_api_key: + sys.stderr.write("NOTION_API_KEY environment variable not set\n") + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path or not os.path.exists(transcript_path): + sys.stderr.write(f"Transcript not found: {transcript_path}\n") + return + + phases = parse_tdd_phases(transcript_path) + + test_class = extract_test_class(command) + test_methods = extract_test_methods(stdout) + timestamp = datetime.now(KST).strftime("%Y-%m-%d %H:%M") + + blocks = build_notion_blocks(test_class, timestamp, phases, test_methods) + append_blocks_to_notion(notion_api_key, blocks) + + +# --------------------------------------------------------------------------- +# Stdout / metadata extraction (unchanged) +# --------------------------------------------------------------------------- + +def extract_stdout(tool_response): + """Extract stdout text from tool_response, handling various formats.""" + if isinstance(tool_response, str): + return tool_response + if isinstance(tool_response, dict): + if "stdout" in tool_response: + return tool_response["stdout"] + if "content" in tool_response: + return str(tool_response["content"]) + if isinstance(tool_response, list): + parts = [] + for item in tool_response: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item.get("text", "")) + return "\n".join(parts) + return str(tool_response) + + +def extract_test_class(command): + """Extract test class name from gradlew test command.""" + match = re.search(r'--tests\s+"?\*?([A-Za-z0-9_.]+)"?', command) + if match: + name = match.group(1) + return name.rsplit(".", 1)[-1] if "." in name else name + return "UnknownTest" + + +def extract_test_methods(stdout): + """Extract executed test method names from test output.""" + methods = [] + for line in stdout.split("\n"): + match = re.search(r">\s+(\w+)\(\)\s+PASSED", line) + if match: + methods.append(match.group(1)) + return methods + + +# --------------------------------------------------------------------------- +# Transcript parsing โ€” TDD phase extraction with AI reasoning +# --------------------------------------------------------------------------- + +def read_recent_entries(transcript_path, max_entries=500): + """Read the most recent entries from a JSONL transcript file.""" + entries = [] + try: + with open(transcript_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + except (OSError, IOError): + return [] + return entries[-max_entries:] + + +def find_tdd_cycle_entries(entries): + """Return entries between the last two gradlew test Bash commands. + + This scopes the parsing to only the current TDD cycle. + """ + test_run_indices = [] + for i, entry in enumerate(entries): + if entry.get("type") != "assistant": + continue + for content in entry.get("message", {}).get("content", []): + if (content.get("type") == "tool_use" + and content.get("name") == "Bash" + and re.search(r"gradlew.*test", + content.get("input", {}).get("command", ""))): + test_run_indices.append(i) + break + + if not test_run_indices: + return entries + + if len(test_run_indices) < 2: + return entries[: test_run_indices[-1]] + + start = test_run_indices[-2] + 1 + end = test_run_indices[-1] + return entries[start:end] + + +def extract_reasoning_from_entry(entry): + """Extract visible reasoning text from an assistant message. + + Prefers ``text`` blocks (visible to user). Falls back to a truncated + ``thinking`` block summary when no text is available. + """ + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + return "" + + text_parts = [] + thinking_parts = [] + + for content in content_list: + if content.get("type") == "text": + t = content.get("text", "").strip() + if t: + text_parts.append(t) + elif content.get("type") == "thinking": + t = content.get("thinking", "").strip() + if t: + thinking_parts.append(t) + + if text_parts: + return "\n".join(text_parts) + if thinking_parts: + combined = "\n".join(thinking_parts) + return combined[:800] + ("..." if len(combined) > 800 else "") + return "" + + +def extract_test_names(tool_input): + """Extract test method names from Write/Edit content.""" + names = [] + content = tool_input.get("content", "") or tool_input.get("new_string", "") + if not content: + return names + + for match in re.finditer( + r"(?:@Test|@DisplayName)\s*(?:\(\"([^\"]+)\"\))?\s*\n\s*(?:void\s+(\w+))?", + content, + ): + display_name = match.group(1) + method_name = match.group(2) + name = display_name or method_name + if name and name not in names: + names.append(name) + + if not names: + for match in re.finditer(r"void\s+(\w+)\s*\(", content): + name = match.group(1) + if name not in names: + names.append(name) + + return names + + +def parse_tdd_phases(transcript_path): + """Parse transcript JSONL into TDD phases with AI reasoning. + + Returns ``{"red": [...], "green": [...], "refactor": [...]}``. + Each entry: ``{"reasoning": str, "files": [str], "test_names": [str]}``. + """ + entries = read_recent_entries(transcript_path, max_entries=500) + cycle_entries = find_tdd_cycle_entries(entries) + + phases = {"red": [], "green": [], "refactor": []} + green_files_seen = set() + + for entry in cycle_entries: + if entry.get("type") != "assistant": + continue + + content_list = entry.get("message", {}).get("content", []) + if not isinstance(content_list, list): + continue + + test_files = [] + source_files = [] + test_names = [] + + for content in content_list: + if content.get("type") != "tool_use": + continue + if content.get("name") not in ("Write", "Edit"): + continue + + file_path = content.get("input", {}).get("file_path", "") + if not file_path: + continue + + filename = os.path.basename(file_path) + + if TEST_FILE_PATTERN.match(filename): + if filename not in test_files: + test_files.append(filename) + test_names.extend( + n for n in extract_test_names(content.get("input", {})) + if n not in test_names + ) + elif JAVA_FILE_PATTERN.match(filename): + if filename not in source_files: + source_files.append(filename) + + if not test_files and not source_files: + continue + + reasoning = extract_reasoning_from_entry(entry) + + if test_files: + phases["red"].append({ + "reasoning": reasoning, + "files": test_files, + "test_names": test_names, + }) + + if source_files: + all_seen = all(f in green_files_seen for f in source_files) + if green_files_seen and all_seen: + phases["refactor"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + else: + phases["green"].append({ + "reasoning": reasoning, + "files": source_files, + "test_names": [], + }) + green_files_seen.update(source_files) + + return phases + + +# --------------------------------------------------------------------------- +# Notion block builders +# --------------------------------------------------------------------------- + +def truncate_text(text, max_len=1900): + """Truncate text to fit Notion rich_text limit (2000 chars).""" + if len(text) <= max_len: + return text + return text[:max_len] + "..." + + +def make_heading2(text): + return { + "object": "block", + "type": "heading_2", + "heading_2": { + "rich_text": [{"type": "text", "text": {"content": truncate_text(text)}}] + }, + } + + +def make_paragraph(text, bold_prefix=None): + rich_text = [] + if bold_prefix: + rich_text.append({ + "type": "text", + "text": {"content": bold_prefix}, + "annotations": {"bold": True}, + }) + + parts = re.split(r"(`[^`]+`)", text) + for part in parts: + if part.startswith("`") and part.endswith("`"): + rich_text.append({ + "type": "text", + "text": {"content": part[1:-1]}, + "annotations": {"code": True}, + }) + elif part: + rich_text.append({ + "type": "text", + "text": {"content": truncate_text(part)}, + }) + + return { + "object": "block", + "type": "paragraph", + "paragraph": {"rich_text": rich_text}, + } + + +def make_bulleted_list(text): + return { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [{"type": "text", "text": {"content": text}}] + }, + } + + +def make_toggle(title, children_blocks, color="default"): + """Create a Notion toggle block with nested children.""" + return { + "object": "block", + "type": "toggle", + "toggle": { + "rich_text": [{"type": "text", "text": {"content": title}}], + "color": color, + "children": children_blocks, + }, + } + + +def make_divider(): + return {"object": "block", "type": "divider", "divider": {}} + + +# --------------------------------------------------------------------------- +# Notion block assembly +# --------------------------------------------------------------------------- + +def build_notion_blocks(test_class, timestamp, phases, test_methods): + """Build Notion API block children payload with AI reasoning per phase.""" + blocks = [] + + blocks.append(make_heading2(f"{test_class} ({timestamp})")) + + # --- Red phase --- + red_summary = _format_red_summary(phases["red"], test_methods) + blocks.append(make_paragraph(red_summary, bold_prefix="Red: ")) + for entry in phases["red"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="red_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"ํŒŒ์ผ: {f}")) + + # --- Green phase --- + green_summary = "ํ…Œ์ŠคํŠธ ํ†ต๊ณผ๋ฅผ ์œ„ํ•œ ๊ตฌํ˜„" if phases["green"] else "ํ…Œ์ŠคํŠธ ํ†ต๊ณผ ํ™•์ธ" + blocks.append(make_paragraph(green_summary, bold_prefix="Green: ")) + for entry in phases["green"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="green_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"ํŒŒ์ผ: {f}")) + + # --- Refactor phase --- + if phases["refactor"]: + blocks.append(make_paragraph("์ฝ”๋“œ ํ’ˆ์งˆ ๊ฐœ์„ ", bold_prefix="Refactor: ")) + for entry in phases["refactor"]: + if entry["reasoning"]: + blocks.append(make_toggle( + "AI Reasoning", + [make_paragraph(truncate_text(entry["reasoning"]))], + color="blue_background", + )) + for f in entry["files"]: + blocks.append(make_bulleted_list(f"ํŒŒ์ผ: {f}")) + + # --- Result --- + blocks.append(make_paragraph("BUILD SUCCESSFUL", bold_prefix="Result: ")) + blocks.append(make_divider()) + + # Safety: Notion allows max 100 blocks per request + return blocks[:100] + + +def _format_red_summary(red_entries, test_methods): + """Format the Red phase summary line.""" + all_names = [] + for entry in red_entries: + all_names.extend(entry["test_names"]) + + if all_names: + names = ", ".join(f"`{n}`" for n in all_names[:5]) + return f"{names} ํ…Œ์ŠคํŠธ ์ž‘์„ฑ" + if test_methods: + names = ", ".join(f"`{m}`" for m in test_methods[:5]) + return f"{names} ํ…Œ์ŠคํŠธ ์ž‘์„ฑ" + return "ํ…Œ์ŠคํŠธ ์ž‘์„ฑ" + + +# --------------------------------------------------------------------------- +# Notion API call (unchanged) +# --------------------------------------------------------------------------- + +def append_blocks_to_notion(api_key, blocks): + """Append blocks to the Notion page using urllib (no external deps).""" + url = f"https://api.notion.com/v1/blocks/{NOTION_PAGE_ID}/children" + payload = json.dumps({"children": blocks}).encode("utf-8") + + req = urllib.request.Request( + url, + data=payload, + method="PATCH", + headers={ + "Authorization": f"Bearer {api_key}", + "Notion-Version": NOTION_API_VERSION, + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status != 200: + sys.stderr.write(f"Notion API returned status {resp.status}\n") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + sys.stderr.write(f"Notion API error {e.code}: {body}\n") + except urllib.error.URLError as e: + sys.stderr.write(f"Notion API connection error: {e.reason}\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..61fadfa1c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "source ~/.zshrc 2>/dev/null; python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-notion-logger.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..5d525720f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew build:*)", + "Bash(java -version:*)", + "Bash(echo:*)", + "Bash(/usr/libexec/java_home:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew build:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home ./gradlew:*)", + "Bash(docker info:*)", + "Bash(docker context inspect:*)", + "Bash(docker run:*)", + "Bash(docker context:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_HOST=unix:///Users/praesentia/.docker/run/docker.sock ./gradlew:*)", + "Bash(~/.testcontainers.properties)", + "Bash(curl:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock ./gradlew:*)", + "Bash(./gradlew:*)", + "Bash(JAVA_HOME=/Users/praesentia/Library/Java/JavaVirtualMachines/azul-21.0.7/Contents/Home DOCKER_API_VERSION=1.44 ./gradlew:*)", + "Bash(python3:*)", + "WebSearch", + "WebFetch(domain:github.com)", + "Bash(osascript:*)", + "Bash(docker:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 5a979af6f..f9bd50072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,4 @@ -HELP.md -.gradle +.DS_Store +.idea/ build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Kotlin ### -.kotlin +.gradle/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d547a18a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,152 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-module Spring Boot 3.4.4 / Java 21 template project with clean architecture. The actual codebase is in `loop-pack-be-l2-vol3-java/`. + +## Build & Test Commands + +```bash +# Build +./gradlew build + +# Run all tests +./gradlew test + +# Run single test class +./gradlew test --tests "ExampleV1ApiE2ETest" + +# Run tests matching pattern +./gradlew test --tests "*ModelTest" + +# Generate coverage report +./gradlew jacocoTestReport +``` + +## Local Development Infrastructure + +```bash +# Start MySQL, Redis (master/replica), Kafka +docker-compose -f ./docker/infra-compose.yml up + +# Start Prometheus + Grafana (localhost:3000, admin/admin) +docker-compose -f ./docker/monitoring-compose.yml up +``` + +## Module Structure + +``` +loop-pack-be-l2-vol3-java/ +โ”œโ”€โ”€ apps/ # Executable Spring Boot applications +โ”‚ โ”œโ”€โ”€ commerce-api # REST API service +โ”‚ โ”œโ”€โ”€ commerce-batch # Batch processing +โ”‚ โ””โ”€โ”€ commerce-streamer # Kafka event streaming +โ”œโ”€โ”€ modules/ # Reusable configuration modules +โ”‚ โ”œโ”€โ”€ jpa # JPA + QueryDSL config +โ”‚ โ”œโ”€โ”€ redis # Redis cache config +โ”‚ โ””โ”€โ”€ kafka # Kafka config +โ””โ”€โ”€ supports/ # Add-on utilities + โ”œโ”€โ”€ jackson # JSON serialization + โ”œโ”€โ”€ logging # Structured logging + โ””โ”€โ”€ monitoring # Prometheus/Grafana metrics +``` + +## Architecture Layers (per app) + +- `interfaces/api/` - REST Controllers + DTOs (request/response records) +- `application/` - Facades/Use Cases (business orchestration) +- `domain/` - Business logic, entities, domain services +- `infrastructure/` - JPA repositories, external integrations + +## Key Conventions + +### Entity Design +All entities extend `BaseEntity` (`modules/jpa/.../domain/BaseEntity.java`): +- Auto-managed: `id`, `createdAt`, `updatedAt`, `deletedAt` +- Soft-delete via idempotent `delete()` / `restore()` methods +- Override `guard()` for validation (called on PrePersist/PreUpdate) + +### Error Handling +Use `CoreException` with `ErrorType` enum: +```java +throw new CoreException(ErrorType.NOT_FOUND); +throw new CoreException(ErrorType.BAD_REQUEST, "Custom message"); +``` +Available: `BAD_REQUEST`, `NOT_FOUND`, `CONFLICT`, `INTERNAL_ERROR` + +### API Response Format +All responses wrapped in `ApiResponse`: +```json +{ + "meta": { "result": "SUCCESS|FAIL", "errorCode": null, "message": null }, + "data": { ... } +} +``` + +### DTO Pattern +Use Java records with nested response classes and static `from()` factories: +```java +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name) { + public static ExampleResponse from(ExampleModel model) { ... } + } +} +``` + +## Testing Strategy + +Three test tiers with naming conventions: +1. **Unit tests** (`*ModelTest`) - Domain logic, no Spring context +2. **Integration tests** (`*IntegrationTest`) - `@SpringBootTest`, uses `DatabaseCleanUp.truncateAllTables()` in `@AfterEach` +3. **E2E tests** (`*E2ETest`) - `@SpringBootTest(webEnvironment=RANDOM_PORT)`, uses `TestRestTemplate` + +Test configuration: +- Profile: `spring.profiles.active=test` +- Timezone: `Asia/Seoul` +- TestContainers for MySQL and Redis + +## Tech Stack + +- Java 21, Spring Boot 3.4.4, Spring Cloud 2024.0.1 +- MySQL 8.0 + JPA + QueryDSL +- Redis 7.0 (master-replica), Kafka 3.5.1 (KRaft mode) +- JUnit 5, Mockito, SpringMockK, Instancio, TestContainers + +## ๊ฐœ๋ฐœ ๊ทœ์น™ +### ์ง„ํ–‰ Workflow - ์ฆ๊ฐ• ์ฝ”๋”ฉ +- **๋Œ€์›์น™** : ๋ฐฉํ–ฅ์„ฑ ๋ฐ ์ฃผ์š” ์˜์‚ฌ ๊ฒฐ์ •์€ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ œ์•ˆ๋งŒ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ตœ์ข… ์Šน์ธ๋œ ์‚ฌํ•ญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘์—…์„ ์ˆ˜ํ–‰. +- **์ค‘๊ฐ„ ๊ฒฐ๊ณผ ๋ณด๊ณ ** : AI ๊ฐ€ ๋ฐ˜๋ณต์ ์ธ ๋™์ž‘์„ ํ•˜๊ฑฐ๋‚˜, ์š”์ฒญํ•˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„, ํ…Œ์ŠคํŠธ ์‚ญ์ œ๋ฅผ ์ž„์˜๋กœ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ๊ฐœ์ž…. +- **์„ค๊ณ„ ์ฃผ๋„๊ถŒ ์œ ์ง€** : AI ๊ฐ€ ์ž„์˜ํŒ๋‹จ์„ ํ•˜์ง€ ์•Š๊ณ , ๋ฐฉํ–ฅ์„ฑ์— ๋Œ€ํ•œ ์ œ์•ˆ ๋“ฑ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜ ๊ฐœ๋ฐœ์ž์˜ ์Šน์ธ์„ ๋ฐ›์€ ํ›„ ์ˆ˜ํ–‰. + +### ๊ฐœ๋ฐœ Workflow - TDD (Red > Green > Refactor) +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋Š” given-when-then ์›์น™์œผ๋กœ ์ž‘์„ฑํ•  ๊ฒƒ +#### 1. Red Phase : ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ๋จผ์ € ์ž‘์„ฑ +- ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜๋Š” ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ž‘์„ฑ +- ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ +#### 2. Green Phase : ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผํ•˜๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- Red Phase ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํ†ต๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- ์˜ค๋ฒ„์—”์ง€๋‹ˆ์–ด๋ง ๊ธˆ์ง€ +#### 3. Refactor Phase : ๋ถˆํ•„์š”ํ•œ ์ฝ”๋“œ ์ œ๊ฑฐ ๋ฐ ํ’ˆ์งˆ ๊ฐœ์„  +- ๋ถˆํ•„์š”ํ•œ private ํ•จ์ˆ˜ ์ง€์–‘, ๊ฐ์ฒด์ง€ํ–ฅ์  ์ฝ”๋“œ ์ž‘์„ฑ +- unused import ์ œ๊ฑฐ +- ์„ฑ๋Šฅ ์ตœ์ ํ™” +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๊ฐ€ ํ†ต๊ณผํ•ด์•ผ ํ•จ +- ## ์ฃผ์˜์‚ฌํ•ญ +### 1. Never Do +- ์‹ค์ œ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š” ์ฝ”๋“œ, ๋ถˆํ•„์š”ํ•œ Mock ๋ฐ์ดํ„ฐ๋ฅผ ์ด์š”ํ•œ ๊ตฌํ˜„์„ ํ•˜์ง€ ๋ง ๊ฒƒ +- null-safety ํ•˜์ง€ ์•Š๊ฒŒ ์ฝ”๋“œ ์ž‘์„ฑํ•˜์ง€ ๋ง ๊ฒƒ (Java ์˜ ๊ฒฝ์šฐ, Optional ์„ ํ™œ์šฉํ•  ๊ฒƒ) +- println ์ฝ”๋“œ ๋‚จ๊ธฐ์ง€ ๋ง ๊ฒƒ + +### 2. Recommendation +- ์‹ค์ œ API ๋ฅผ ํ˜ธ์ถœํ•ด ํ™•์ธํ•˜๋Š” E2E ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด ์„ค๊ณ„ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ๋Œ€์•ˆ ๋ฐ ์ œ์•ˆ +- ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ API ์˜ ๊ฒฝ์šฐ, `.http/**.http` ์— ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑ + +### 3. Priority +1. ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…๋งŒ ๊ณ ๋ ค +2. null-safety, thread-safety ๊ณ ๋ ค +3. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ +4. ๊ธฐ์กด ์ฝ”๋“œ ํŒจํ„ด ๋ถ„์„ ํ›„ ์ผ๊ด€์„ฑ ์œ ์ง€ \ No newline at end of file diff --git a/docs/member-implementation-plan.md b/docs/member-implementation-plan.md new file mode 100644 index 000000000..0ace6419d --- /dev/null +++ b/docs/member-implementation-plan.md @@ -0,0 +1,455 @@ +# Member ๊ธฐ๋Šฅ ๊ตฌํ˜„ ๊ณ„ํš + +## ์š”๊ตฌ์‚ฌํ•ญ ์ƒ์„ธ + +### ํšŒ์›๊ฐ€์ž… +- **ํ•„์š” ์ •๋ณด**: loginId, password, name, birthDate, email +- ์ด๋ฏธ ๊ฐ€์ž…๋œ loginId๋กœ๋Š” ๊ฐ€์ž… ๋ถˆ๊ฐ€ +- ๊ฐ ์ •๋ณด ํฌ๋งท ๊ฒ€์ฆ ํ•„์š”: + - **loginId**: ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ + - **name**: ํ•„์ˆ˜๊ฐ’ + - **email**: ์ด๋ฉ”์ผ ํ˜•์‹ + - **birthDate**: ๋‚ ์งœ ํ˜•์‹ +- ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”ํ•ด ์ €์žฅ + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ +- 8~16์ž +- ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ํ—ˆ์šฉ +- ์ƒ๋…„์›”์ผ ํฌํ•จ ๋ถˆ๊ฐ€ (YYYYMMDD, YYMMDD ํ˜•์‹ ๋ชจ๋‘) +- (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์‹œ) ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์‚ฌ์šฉ ๋ถˆ๊ฐ€ + +### ๋‚ด ์ •๋ณด ์กฐํšŒ +- **์ธ์ฆ ๋ฐฉ์‹**: ํ—ค๋”๋กœ ์ „๋‹ฌ + - `X-Loopers-LoginId`: ๋กœ๊ทธ์ธ ID + - `X-Loopers-LoginPw`: ๋น„๋ฐ€๋ฒˆํ˜ธ +- **๋ฐ˜ํ™˜ ์ •๋ณด**: loginId, name, birthDate, email +- **๋งˆ์Šคํ‚น**: ์ด๋ฆ„์˜ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ `*`๋กœ ๋งˆ์Šคํ‚น + - ์˜ˆ: "ํ™๊ธธ๋™" โ†’ "ํ™๊ธธ*" + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • +- **ํ•„์š” ์ •๋ณด**: ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ +- ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜ ํ™•์ธ ํ•„์ˆ˜ +- ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ์ ์šฉ + ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์‚ฌ์šฉ ๋ถˆ๊ฐ€ + +--- + +## API ์„ค๊ณ„ + +| ๊ธฐ๋Šฅ | Method | Endpoint | ์ธ์ฆ | +|------|--------|----------|------| +| ํšŒ์›๊ฐ€์ž… | POST | `/api/v1/members` | ๋ถˆํ•„์š” | +| ๋‚ด์ •๋ณด์กฐํšŒ | GET | `/api/v1/members/me` | ํ—ค๋” ์ธ์ฆ | +| ๋น„๋ฐ€๋ฒˆํ˜ธ๋ณ€๊ฒฝ | PATCH | `/api/v1/members/me/password` | ํ—ค๋” ์ธ์ฆ | + +### Request/Response ์˜ˆ์‹œ + +#### ํšŒ์›๊ฐ€์ž… +```http +POST /api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "1990-01-15", + "email": "test@example.com" +} +``` + +#### ๋‚ด ์ •๋ณด ์กฐํšŒ +```http +GET /api/v1/members/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +``` +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "loginId": "testuser", + "name": "ํ™๊ธธ*", + "birthDate": "1990-01-15", + "email": "test@example.com" + } +} +``` + +#### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +```http +PATCH /api/v1/members/me/password +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass5678!" +} +``` + +--- + +## TDD ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ์ „๋žต + +### ์›์น™: "๊ฐ€์žฅ ๋‹จ์ˆœํ•œ ๊ฒƒ" ๋˜๋Š” "๊ฐ€์žฅ ์˜ˆ์™ธ์ ์ธ ๊ฒƒ"๋ถ€ํ„ฐ + +TDD์—์„œ ํ…Œ์ŠคํŠธ ์ˆœ์„œ๋ฅผ ์ •ํ•˜๋Š” ๋‘ ๊ฐ€์ง€ ์ ‘๊ทผ๋ฒ•: + +| ์ ‘๊ทผ๋ฒ• | ์„ค๋ช… | ์žฅ์  | +|--------|------|------| +| **Simplest First** | ๊ฐ€์žฅ ๋‹จ์ˆœํ•œ ์„ฑ๊ณต ์ผ€์ด์Šค๋ถ€ํ„ฐ | ๋น ๋ฅด๊ฒŒ ๋™์ž‘ํ•˜๋Š” ์ฝ”๋“œ ํ™•๋ณด | +| **Edge First** | ๊ฐ€์žฅ ์˜ˆ์™ธ์ ์ธ/๊ฒฝ๊ณ„ ์ผ€์ด์Šค๋ถ€ํ„ฐ | ๊ฒฌ๊ณ ํ•œ ๊ฒ€์ฆ ๋กœ์ง ๋จผ์ € ํ™•๋ณด | + +### ๊ถŒ์žฅ: ํ˜ผํ•ฉ ์ „๋žต (Zombie ๋ฐฉ๋ฒ•๋ก ) + +``` +Z - Zero (๋นˆ ๊ฐ’, null) +O - One (๋‹จ์ผ ๊ฐ’, ์ •์ƒ ์ผ€์ด์Šค ํ•˜๋‚˜) +M - Many (์—ฌ๋Ÿฌ ๊ฐ’, ๊ฒฝ๊ณ„๊ฐ’) +B - Boundary (๊ฒฝ๊ณ„ ์กฐ๊ฑด) +I - Interface (์ž…์ถœ๋ ฅ ํ˜•์‹) +E - Exception (์˜ˆ์™ธ ์ƒํ™ฉ) +``` + +**์‹ค์ œ ์ ์šฉ ์ˆœ์„œ:** +1. **Zero/Null** โ†’ ๊ฐ€์žฅ ๋‹จ์ˆœํ•œ ์˜ˆ์™ธ (null, ๋นˆ ๊ฐ’) +2. **One** โ†’ ์ •์ƒ ๋™์ž‘ ํ•˜๋‚˜ +3. **Boundary** โ†’ ๊ฒฝ๊ณ„๊ฐ’ (8์ž, 16์ž ๋“ฑ) +4. **Exception** โ†’ ๋น„์ฆˆ๋‹ˆ์Šค ์˜ˆ์™ธ (์ค‘๋ณต, ๊ทœ์น™ ์œ„๋ฐ˜) + +--- + +## TDD ๊ตฌํ˜„ ์ˆœ์„œ (์ƒ์„ธ) + +### Phase 1: PasswordValidator (๋‹จ์œ„ ํ…Œ์ŠคํŠธ) - ์ˆœ์ˆ˜ Java + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `PasswordValidatorTest.java` + +**์ž‘์„ฑ ์ˆœ์„œ (๊ถŒ์žฅ):** + +``` +1. [Zero] null ๋˜๋Š” ๋นˆ ๋ฌธ์ž์—ด โ†’ BAD_REQUEST +2. [Boundary] ์ •ํ™•ํžˆ 8์ž โ†’ ์„ฑ๊ณต +3. [Boundary] 7์ž (๊ฒฝ๊ณ„-1) โ†’ BAD_REQUEST +4. [Boundary] ์ •ํ™•ํžˆ 16์ž โ†’ ์„ฑ๊ณต +5. [Boundary] 17์ž (๊ฒฝ๊ณ„+1) โ†’ BAD_REQUEST +6. [Exception] ํ—ˆ์šฉ๋˜์ง€ ์•Š๋Š” ๋ฌธ์ž (ํ•œ๊ธ€) โ†’ BAD_REQUEST +7. [Exception] ์ƒ๋…„์›”์ผ YYYYMMDD ํฌํ•จ โ†’ BAD_REQUEST +8. [Exception] ์ƒ๋…„์›”์ผ YYMMDD ํฌํ•จ โ†’ BAD_REQUEST +9. [One] ๋ชจ๋“  ๊ทœ์น™ ํ†ต๊ณผ โ†’ ์„ฑ๊ณต +``` + +| # | ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ๋ช… | ์ž…๋ ฅ ์˜ˆ์‹œ | ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ | +|---|----------------|----------|----------| +| 1 | `validate_WithNull_ThrowsBadRequest` | `null` | BAD_REQUEST | +| 2 | `validate_WithExactly8Chars_Succeeds` | `"Abcd123!"` | ์„ฑ๊ณต | +| 3 | `validate_With7Chars_ThrowsBadRequest` | `"Abc123!"` | BAD_REQUEST | +| 4 | `validate_WithExactly16Chars_Succeeds` | `"Abcd1234!@#$Efgh"` | ์„ฑ๊ณต | +| 5 | `validate_With17Chars_ThrowsBadRequest` | `"Abcd1234!@#$Efghi"` | BAD_REQUEST | +| 6 | `validate_WithKorean_ThrowsBadRequest` | `"Abcd123ํ•œ๊ธ€"` | BAD_REQUEST | +| 7 | `validate_ContainsBirthYYYYMMDD_ThrowsBadRequest` | `"Pass19900115!"` (์ƒ๋…„์›”์ผ: 1990-01-15) | BAD_REQUEST | +| 8 | `validate_ContainsBirthYYMMDD_ThrowsBadRequest` | `"Pass900115!!"` (์ƒ๋…„์›”์ผ: 1990-01-15) | BAD_REQUEST | +| 9 | `validate_WithValidPassword_Succeeds` | `"ValidPass1!"` | ์„ฑ๊ณต | + +### Phase 2: NameMasker (๋‹จ์œ„ ํ…Œ์ŠคํŠธ) - ์ˆœ์ˆ˜ Java + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `NameMaskerTest.java` + +**์ž‘์„ฑ ์ˆœ์„œ:** + +``` +1. [Zero] null โ†’ null ๋ฐ˜ํ™˜ ๋˜๋Š” ์˜ˆ์™ธ +2. [Zero] ๋นˆ ๋ฌธ์ž์—ด โ†’ ๋นˆ ๋ฌธ์ž์—ด +3. [Boundary] 1๊ธ€์ž โ†’ "*" +4. [Boundary] 2๊ธ€์ž โ†’ "ํ™*" +5. [One] 3๊ธ€์ž ์ด์ƒ โ†’ "ํ™๊ธธ*" +``` + +| # | ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ๋ช… | ์ž…๋ ฅ | ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ | +|---|----------------|------|----------| +| 1 | `mask_WithNull_ReturnsNull` | `null` | `null` | +| 2 | `mask_WithEmpty_ReturnsEmpty` | `""` | `""` | +| 3 | `mask_With1Char_ReturnsMasked` | `"ํ™"` | `"*"` | +| 4 | `mask_With2Chars_ReturnsMasked` | `"ํ™๊ธธ"` | `"ํ™*"` | +| 5 | `mask_With3Chars_ReturnsMasked` | `"ํ™๊ธธ๋™"` | `"ํ™๊ธธ*"` | + +### Phase 3: LoginIdValidator (๋‹จ์œ„ ํ…Œ์ŠคํŠธ) - ์ˆœ์ˆ˜ Java + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `LoginIdValidatorTest.java` + +**์ž‘์„ฑ ์ˆœ์„œ:** + +``` +1. [Zero] null โ†’ BAD_REQUEST +2. [Zero] ๋นˆ ๋ฌธ์ž์—ด โ†’ BAD_REQUEST +3. [Exception] ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ โ†’ BAD_REQUEST +4. [Exception] ํ•œ๊ธ€ ํฌํ•จ โ†’ BAD_REQUEST +5. [One] ์˜๋ฌธ+์ˆซ์ž โ†’ ์„ฑ๊ณต +6. [One] ์˜๋ฌธ๋งŒ โ†’ ์„ฑ๊ณต +7. [One] ์ˆซ์ž๋งŒ โ†’ ์„ฑ๊ณต +``` + +### Phase 4: MemberModel (๋‹จ์œ„ ํ…Œ์ŠคํŠธ) + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `MemberModelTest.java` + +**์ž‘์„ฑ ์ˆœ์„œ:** + +``` +1. [Zero] loginId null โ†’ BAD_REQUEST +2. [Zero] name null โ†’ BAD_REQUEST +3. [One] ์ •์ƒ ์ƒ์„ฑ โ†’ ์„ฑ๊ณต +4. [One] changePassword ํ˜ธ์ถœ โ†’ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ๋จ +``` + +| # | ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ๋ช… | ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ | +|---|----------------|----------| +| 1 | `create_WithNullLoginId_ThrowsBadRequest` | BAD_REQUEST | +| 2 | `create_WithNullName_ThrowsBadRequest` | BAD_REQUEST | +| 3 | `create_WithValidInput_Succeeds` | ์„ฑ๊ณต | +| 4 | `changePassword_UpdatesPassword` | ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ๋จ | + +### Phase 5: MemberService (ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ) + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `MemberServiceIntegrationTest.java` + +**์ž‘์„ฑ ์ˆœ์„œ:** + +``` +1. [One] ์ •์ƒ ํšŒ์›๊ฐ€์ž… โ†’ ์„ฑ๊ณต, ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™”๋จ +2. [Exception] ์ค‘๋ณต loginId โ†’ CONFLICT +3. [One] ์กด์žฌํ•˜๋Š” ํšŒ์› ์กฐํšŒ โ†’ ํšŒ์› ๋ฐ˜ํ™˜ +4. [Exception] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ์› ์กฐํšŒ โ†’ NOT_FOUND +5. [One] ํ—ค๋” ์ธ์ฆ ์„ฑ๊ณต โ†’ ํšŒ์› ๋ฐ˜ํ™˜ +6. [Exception] ํ—ค๋” ์ธ์ฆ ์‹คํŒจ (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜) โ†’ UNAUTHORIZED +7. [One] ์ •์ƒ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ โ†’ ์„ฑ๊ณต +8. [Exception] ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜ โ†’ BAD_REQUEST +9. [Exception] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ์œ„๋ฐ˜ โ†’ BAD_REQUEST +``` + +### Phase 6: API E2E ํ…Œ์ŠคํŠธ + +**ํ…Œ์ŠคํŠธ ํŒŒ์ผ**: `MemberV1ApiE2ETest.java` + +**์ž‘์„ฑ ์ˆœ์„œ:** + +``` +1. [One] POST ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต โ†’ 200 +2. [Exception] POST ์ค‘๋ณต loginId โ†’ 409 +3. [Exception] POST ์ž˜๋ชป๋œ loginId ํ˜•์‹ โ†’ 400 +4. [One] GET ๋‚ด ์ •๋ณด ์กฐํšŒ ์„ฑ๊ณต โ†’ 200, ์ด๋ฆ„ ๋งˆ์Šคํ‚น๋จ +5. [Exception] GET ์ธ์ฆ ์‹คํŒจ โ†’ 401 +6. [One] PATCH ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์„ฑ๊ณต โ†’ 200 +7. [Exception] PATCH ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜ โ†’ 400 +8. [Exception] PATCH ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ์œ„๋ฐ˜ โ†’ 400 +``` + +--- + +## ๊ตฌํ˜„ ํŒŒ์ผ ๋ชฉ๋ก + +### 1. ์˜์กด์„ฑ ์ถ”๊ฐ€ +``` +apps/commerce-api/build.gradle.kts # spring-security-crypto ์ถ”๊ฐ€ +``` + +### 2. ์„ค์ • +``` +apps/commerce-api/src/main/java/com/loopers/config/ +โ””โ”€โ”€ PasswordEncoderConfig.java # BCryptPasswordEncoder Bean +``` + +### 3. Domain Layer +``` +apps/commerce-api/src/main/java/com/loopers/domain/member/ +โ”œโ”€โ”€ MemberModel.java # ์—”ํ‹ฐํ‹ฐ (BaseEntity ํ™•์žฅ) +โ”œโ”€โ”€ MemberRepository.java # ์ €์žฅ์†Œ ์ธํ„ฐํŽ˜์ด์Šค +โ”œโ”€โ”€ MemberService.java # ๋„๋ฉ”์ธ ์„œ๋น„์Šค +โ”œโ”€โ”€ PasswordValidator.java # ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ +โ”œโ”€โ”€ LoginIdValidator.java # ๋กœ๊ทธ์ธID ๊ฒ€์ฆ +โ””โ”€โ”€ NameMasker.java # ์ด๋ฆ„ ๋งˆ์Šคํ‚น +``` + +### 4. Application Layer +``` +apps/commerce-api/src/main/java/com/loopers/application/member/ +โ”œโ”€โ”€ MemberFacade.java # ์œ ์Šค์ผ€์ด์Šค ์กฐ์œจ +โ””โ”€โ”€ MemberInfo.java # DTO (record) +``` + +### 5. Infrastructure Layer +``` +apps/commerce-api/src/main/java/com/loopers/infrastructure/member/ +โ”œโ”€โ”€ MemberJpaRepository.java # Spring Data JPA +โ””โ”€โ”€ MemberRepositoryImpl.java # Repository ๊ตฌํ˜„์ฒด +``` + +### 6. Interfaces Layer +``` +apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/ +โ”œโ”€โ”€ MemberV1ApiSpec.java # Swagger ๋ช…์„ธ +โ”œโ”€โ”€ MemberV1Controller.java # REST Controller +โ””โ”€โ”€ MemberV1Dto.java # Request/Response DTO +``` + +### 7. HTTP ํ…Œ์ŠคํŠธ +``` +http/commerce-api/member-v1.http # API ํ…Œ์ŠคํŠธ์šฉ +``` + +--- + +## ํ•ต์‹ฌ ๊ตฌํ˜„ ์‚ฌํ•ญ + +### PasswordValidator.java +```java +public class PasswordValidator { + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + public static void validate(String password, LocalDate birthDate) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (!ALLOWED_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); + } + if (containsBirthDate(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์„ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + public static void validateForChange(String newPassword, LocalDate birthDate, + String currentEncodedPassword, PasswordEncoder encoder) { + validate(newPassword, birthDate); + if (encoder.matches(newPassword, currentEncodedPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private static boolean containsBirthDate(String password, LocalDate birthDate) { + if (birthDate == null) return false; + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); // 19900115 + String yymmdd = yyyymmdd.substring(2); // 900115 + return password.contains(yyyymmdd) || password.contains(yymmdd); + } +} +``` + +### NameMasker.java +```java +public class NameMasker { + private static final char MASK_CHAR = '*'; + + public static String mask(String name) { + if (name == null) return null; + if (name.isEmpty()) return ""; + if (name.length() == 1) return String.valueOf(MASK_CHAR); + return name.substring(0, name.length() - 1) + MASK_CHAR; + } +} +``` + +### LoginIdValidator.java +```java +public class LoginIdValidator { + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + public static void validate(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); + } + } +} +``` + +### MemberModel.java +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; // BCrypt ์•”ํ˜ธํ™” + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, + LocalDate birthDate, String email) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + // getters... +} +``` + +--- + +## ์ฐธ์กฐ ํŒŒ์ผ (๊ธฐ์กด ํŒจํ„ด) +- `domain/example/ExampleModel.java` - Entity ํŒจํ„ด +- `domain/example/ExampleService.java` - Service ํŒจํ„ด +- `interfaces/api/ExampleV1ApiE2ETest.java` - E2E ํ…Œ์ŠคํŠธ ํŒจํ„ด +- `modules/jpa/.../BaseEntity.java` - BaseEntity ๊ตฌ์กฐ + +--- + +## ๊ฒ€์ฆ ๋ฐฉ๋ฒ• + +### 1. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ +```bash +./gradlew test --tests "*PasswordValidatorTest" +./gradlew test --tests "*NameMaskerTest" +./gradlew test --tests "*LoginIdValidatorTest" +./gradlew test --tests "*MemberModelTest" +``` + +### 2. ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ +```bash +./gradlew test --tests "*MemberServiceIntegrationTest" +``` + +### 3. E2E ํ…Œ์ŠคํŠธ +```bash +./gradlew test --tests "*MemberV1ApiE2ETest" +``` + +### 4. HTTP ํŒŒ์ผ๋กœ ์ˆ˜๋™ ํ…Œ์ŠคํŠธ +```bash +# ์ธํ”„๋ผ ์‹คํ–‰ +docker-compose -f ./docker/infra-compose.yml up + +# ์•ฑ ์‹คํ–‰ ํ›„ http/commerce-api/member-v1.http ์‹คํ–‰ +``` From 95da7027b8497f5a01189abb71c71c815fa108b7 Mon Sep 17 00:00:00 2001 From: praesentia Date: Wed, 4 Feb 2026 06:08:24 +0900 Subject: [PATCH 03/19] =?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 03ce68f02..9ad4d8ea9 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..8d493491a 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 04/19] =?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 000000000..948d99b1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + private static final Pattern PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + + @Column(name = "email") + private String value; + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_EMAIL); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java new file mode 100644 index 000000000..0f7da8a33 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/LoginId.java @@ -0,0 +1,44 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + @Column(name = "login_id", nullable = false, unique = true) + private String value; + + public LoginId(String value) { + if (value == null || value.isBlank() || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_LOGIN_ID); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 000000000..e0ffb37cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Column(nullable = false) + private String password; + + @Embedded + private MemberName name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Embedded + private Email email; + + protected MemberModel() {} + + public MemberModel(LoginId loginId, String encodedPassword, MemberName name, + LocalDate birthDate, Email email) { + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public boolean matchesPassword(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.password); + } + + public String encodedPassword() { + return password; + } + + public LoginId loginId() { + return loginId; + } + + public MemberName name() { + return name; + } + + public LocalDate birthDate() { + return birthDate; + } + + public Email email() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java new file mode 100644 index 000000000..7c850b274 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberName.java @@ -0,0 +1,50 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberName { + + private static final char MASK_CHAR = '*'; + + @Column(name = "name", nullable = false) + private String value; + + public MemberName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_NAME); + } + this.value = value; + } + + public String value() { + return value; + } + + public String masked() { + if (value.length() == 1) { + return String.valueOf(MASK_CHAR); + } + return value.substring(0, value.length() - 1) + MASK_CHAR; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..8ed51f87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + MemberModel save(MemberModel member); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java new file mode 100644 index 000000000..dbb4ab6dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Password.java @@ -0,0 +1,52 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern ALLOWED_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{}|;':\",./<>?]+$"); + + private final String value; + + public static Password of(String value, LocalDate birthDate) { + Password password = new Password(value); + password.validateAgainst(birthDate); + return password; + } + + public Password(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + if (!ALLOWED_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.INVALID_PASSWORD); + } + this.value = value; + } + + public void validateAgainst(LocalDate birthDate) { + if (birthDate == null) { + return; + } + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String yymmdd = yyyymmdd.substring(2); + if (value.contains(yyyymmdd) || value.contains(yymmdd)) { + throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + public String value() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..2eb5cfe87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginIdValue(String value); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..5f7f6e00d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginIdValue(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491a..2c55138ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -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 000000000..6030b304a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/EmailTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailTest { + + @DisplayName("Email ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ด๋ฉ”์ผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void validEmailCreatesSuccessfully() { + // given + String value = "kwonmo@example.com"; + + // when + Email email = new Email(value); + + // then + assertThat(email.value()).isEqualTo(value); + } + + @DisplayName("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์œผ๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + @ParameterizedTest + @ValueSource(strings = {"", "testexample.com", "test@", "@example.com"}) + void rejectsInvalidEmailFormats(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Email(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_EMAIL); + } + } + + @DisplayName("๋™๋“ฑ์„ฑ ๋น„๊ต") + @Nested + class Equals { + + @DisplayName("๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค") + @Test + void sameValueMeansEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("kwonmo@example.com"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค") + @Test + void differentValueMeansNotEqual() { + // given + Email one = new Email("kwonmo@example.com"); + Email another = new Email("jihun@example.com"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java new file mode 100644 index 000000000..98ca41492 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/LoginIdTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginIdTest { + + @DisplayName("LoginId ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("null, ๋นˆ ๋ฌธ์ž์—ด, ๊ณต๋ฐฑ์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("ํŠน์ˆ˜๋ฌธ์ž๋‚˜ ํ•œ๊ธ€์ด ํฌํ•จ๋˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test์œ ์ €", "hello world!", "user#1"}) + void rejectsInvalidCharacters(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new LoginId(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("์˜๋ฌธ, ์ˆซ์ž ์กฐํ•ฉ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @ParameterizedTest + @ValueSource(strings = {"testuser", "12345", "user123"}) + void acceptsAlphanumericValues(String value) { + // given & when + LoginId loginId = new LoginId(value); + + // then + assertThat(loginId.value()).isEqualTo(value); + } + } + + @DisplayName("๋™๋“ฑ์„ฑ ๋น„๊ต") + @Nested + class Equals { + + @DisplayName("๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค") + @Test + void sameValueMeansEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("kwonmo"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค") + @Test + void differentValueMeansNotEqual() { + // given + LoginId one = new LoginId("kwonmo"); + LoginId another = new LoginId("jihun"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 000000000..ff9a0e1be --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberModelTest { + + @Mock + private PasswordEncoder passwordEncoder; + + @DisplayName("ํšŒ์› ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("์–‘๊ถŒ๋ชจ"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + Email email = new Email("kwonmo@example.com"); + when(passwordEncoder.matches("rawPassword", "encodedPassword")).thenReturn(true); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, email); + + // then + assertAll( + () -> assertThat(member.loginId()).isEqualTo(loginId), + () -> assertThat(member.matchesPassword("rawPassword", passwordEncoder)).isTrue(), + () -> assertThat(member.name()).isEqualTo(name), + () -> assertThat(member.birthDate()).isEqualTo(birthDate), + () -> assertThat(member.email()).isEqualTo(email) + ); + } + + @DisplayName("email์ด null์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithNullEmail() { + // given + LoginId loginId = new LoginId("kwonmo"); + String encodedPassword = "encodedPassword"; + MemberName name = new MemberName("์–‘๊ถŒ๋ชจ"); + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + // when + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, null); + + // then + assertThat(member.email()).isNull(); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ") + @Nested + class ChangePassword { + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋งค์นญ๋˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void newPasswordReplacesOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "oldEncodedPassword", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com") + ); + String newEncodedPassword = "newEncodedPassword"; + when(passwordEncoder.matches("newRaw", "newEncodedPassword")).thenReturn(true); + when(passwordEncoder.matches("oldRaw", "newEncodedPassword")).thenReturn(false); + + // when + member.changePassword(newEncodedPassword); + + // then + assertThat(member.matchesPassword("newRaw", passwordEncoder)).isTrue(); + assertThat(member.matchesPassword("oldRaw", passwordEncoder)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java new file mode 100644 index 000000000..d72086b0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberNameTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberNameTest { + + @DisplayName("MemberName ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("null, ๋นˆ ๋ฌธ์ž์—ด, ๊ณต๋ฐฑ์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new MemberName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_NAME); + } + + @DisplayName("์œ ํšจํ•œ ์ด๋ฆ„์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "์–‘๊ถŒ๋ชจ"; + + // when + MemberName name = new MemberName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("์ด๋ฆ„ ๋งˆ์Šคํ‚น") + @Nested + class Masked { + + @DisplayName("๊ธ€์ž ์ˆ˜์— ๋”ฐ๋ผ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @ParameterizedTest + @CsvSource({"์–‘, *", "์–‘๊ถŒ, ์–‘*", "์–‘๊ถŒ๋ชจ, ์–‘๊ถŒ*"}) + void masksLastCharacter(String input, String expected) { + // given + MemberName name = new MemberName(input); + + // when + String result = name.masked(); + + // then + assertThat(result).isEqualTo(expected); + } + } + + @DisplayName("๋™๋“ฑ์„ฑ ๋น„๊ต") + @Nested + class Equals { + + @DisplayName("๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค") + @Test + void sameValueMeansEqual() { + // given + MemberName one = new MemberName("์–‘๊ถŒ๋ชจ"); + MemberName another = new MemberName("์–‘๊ถŒ๋ชจ"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค") + @Test + void differentValueMeansNotEqual() { + // given + MemberName one = new MemberName("์–‘๊ถŒ๋ชจ"); + MemberName another = new MemberName("๋ฐ•์ง€ํ›ˆ"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..02c2fea85 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,90 @@ +package com.loopers.domain.member; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("loginId๋กœ ํšŒ์› ์กฐํšŒ") + @Nested + class FindByLoginId { + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด ๋นˆ Optional์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsEmptyForNonExistentLoginId() { + // given + String loginId = "nonexistent"; + + // when + Optional result = memberRepository.findByLoginId(loginId); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("์กด์žฌํ•˜๋Š” loginId๋ฉด ์ €์žฅ๋œ ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMemberForExistingLoginId() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + memberRepository.save(member); + + // when + Optional result = memberRepository.findByLoginId("kwonmo"); + + // then + assertThat(result).isPresent(); + assertAll( + () -> assertThat(result.get().loginId().value()).isEqualTo("kwonmo"), + () -> assertThat(result.get().name().value()).isEqualTo("์–‘๊ถŒ๋ชจ"), + () -> assertThat(result.get().birthDate()).isEqualTo(LocalDate.of(1998, 9, 16)), + () -> assertThat(result.get().email().value()).isEqualTo("kwonmo@example.com") + ); + } + } + + @DisplayName("ํšŒ์› ์ €์žฅ") + @Nested + class Save { + + @DisplayName("์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋ฉด ID๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค") + @Test + void generatesIdOnSave() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "Test1234!", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + // when + MemberModel saved = memberRepository.save(member); + + // then + assertThat(saved.getId()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java new file mode 100644 index 000000000..8caedef1b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/PasswordTest.java @@ -0,0 +1,173 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordTest { + + @DisplayName("Password ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("null์ด๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + @ParameterizedTest + @NullAndEmptySource + void rejectsNullOrEmpty(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void validPasswordCreatesSuccessfully() { + // given + String value = "ValidPass1!"; + + // when + Password password = new Password(value); + + // then + assertThat(password.value()).isEqualTo(value); + } + + @DisplayName("8~16์ž ๋ฒ”์œ„ ๋‚ด์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ—ˆ์šฉ๋œ๋‹ค") + @ParameterizedTest + @ValueSource(strings = {"Abcd123!", "Abcd1234!@#$Efgh"}) + void acceptsPasswordsWithinLengthRange(String value) { + // given & when & then + assertDoesNotThrow(() -> new Password(value)); + } + + @DisplayName("8~16์ž ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + @ParameterizedTest + @ValueSource(strings = {"Abc123!", "Abcd1234!@#$Efghi"}) + void rejectsPasswordsOutsideLengthRange(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("ํ•œ๊ธ€์ด ํฌํ•จ๋˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + @Test + void rejectsKoreanCharacters() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Password("Abcd123ํ•œ๊ธ€")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + } + + @DisplayName("์ƒ๋…„์›”์ผ ํฌํ•จ ์—ฌ๋ถ€ ๊ฒ€์ฆ") + @Nested + class ValidateAgainst { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1998, 9, 16); + + @DisplayName("YYYYMMDD ํ˜•์‹์˜ ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void rejectsPasswordContainingFullBirthDate() { + // given + Password password = new Password("Pass19980916!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("YYMMDD ํ˜•์‹์˜ ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜์–ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void rejectsPasswordContainingShortBirthDate() { + // given + Password password = new Password("Pass980916!!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + password.validateAgainst(BIRTH_DATE)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜์ง€ ์•Š์œผ๋ฉด ํ†ต๊ณผํ•œ๋‹ค") + @Test + void passesWhenBirthDateNotIncluded() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(BIRTH_DATE)); + } + + @DisplayName("birthDate๊ฐ€ null์ด๋ฉด ๊ฒ€์ฆ์„ ๊ฑด๋„ˆ๋›ด๋‹ค") + @Test + void skipsValidationWhenBirthDateIsNull() { + // given + Password password = new Password("ValidPass1!"); + + // when & then + assertDoesNotThrow(() -> password.validateAgainst(null)); + } + } + + @DisplayName("of ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ") + @Nested + class OfFactory { + + @DisplayName("์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒ๋…„์›”์ผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidPasswordAndBirthDate() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", LocalDate.of(1998, 9, 16))); + } + + @DisplayName("ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void rejectsInvalidFormat() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("short", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ฑฐ๋ถ€ํ•œ๋‹ค") + @Test + void rejectsPasswordWithBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + Password.of("Pass19980916!", LocalDate.of(1998, 9, 16))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด null์ด๋ฉด ํ˜•์‹๋งŒ ๊ฒ€์ฆํ•œ๋‹ค") + @Test + void onlyValidatesFormatWhenBirthDateIsNull() { + // given & when & then + assertDoesNotThrow(() -> Password.of("ValidPass1!", null)); + } + } +} From 3a9f4184540cd67d81ffdf0964ec1730263e8939 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:17:09 +0900 Subject: [PATCH 05/19] =?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 000000000..89505937d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberSignupService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberSignupService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public MemberModel signup(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + MemberName nameVo = new MemberName(name); + Email emailVo = email != null ? new Email(email) : null; + Password password = Password.of(rawPassword, birthDate); + + memberRepository.findByLoginId(loginId).ifPresent(m -> { + throw new CoreException(ErrorType.DUPLICATE_LOGIN_ID); + }); + + String encodedPassword = passwordEncoder.encode(rawPassword); + MemberModel member = new MemberModel(loginIdVo, encodedPassword, nameVo, birthDate, emailVo); + return memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java new file mode 100644 index 000000000..8fc4fa9ba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberSignupServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…") + @Nested + class Signup { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด ํšŒ์›์ด ์ƒ์„ฑ๋˜๊ณ  ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•”ํ˜ธํ™”๋œ๋‹ค") + @Test + void createsMemberWithEncodedPassword() { + // given + String loginId = "kwonmo"; + String password = "Test1234!"; + String name = "์–‘๊ถŒ๋ชจ"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + String email = "kwonmo@example.com"; + + // when + MemberModel result = memberSignupService.signup(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.loginId().value()).isEqualTo(loginId), + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.birthDate()).isEqualTo(birthDate), + () -> assertThat(result.email().value()).isEqualTo(email), + () -> assertThat(result.matchesPassword(password, passwordEncoder)).isTrue() + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” loginId๋กœ ๊ฐ€์ž…ํ•˜๋ฉด DUPLICATE_LOGIN_ID ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnDuplicateLoginId() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Other1234!", "๋ฐ•์ง€ํ›ˆ", + LocalDate.of(1995, 5, 20), "jihun@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + } + + @DisplayName("loginId ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด INVALID_LOGIN_ID ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user", "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ฑฐ๋ถ€ํ•œ๋‹ค") + @Test + void rejectsPasswordContainingBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "Pass19980916!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java new file mode 100644 index 000000000..a2441df86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberSignupServiceTest.java @@ -0,0 +1,114 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberSignupServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberSignupService memberSignupService; + + @BeforeEach + void setUp() { + memberSignupService = new MemberSignupService(memberRepository, passwordEncoder); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…") + @Nested + class Signup { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์•”ํ˜ธํ™”ํ•˜๊ณ  ์ €์žฅํ•œ๋‹ค") + @Test + void encodesPasswordAndSaves() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + String encodedPassword = "encoded_password"; + LocalDate birthDate = LocalDate.of(1998, 9, 16); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when + MemberModel result = memberSignupService.signup(loginId, rawPassword, "์–‘๊ถŒ๋ชจ", birthDate, "kwonmo@example.com"); + + // then + assertThat(result.matchesPassword(rawPassword, passwordEncoder)).isTrue(); + verify(memberRepository).save(any(MemberModel.class)); + verify(passwordEncoder).encode(rawPassword); + } + + @DisplayName("์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ loginId๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnDuplicateLoginId() { + // given + String loginId = "kwonmo"; + MemberModel existing = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("๊ธฐ์กดํšŒ์›"), + LocalDate.of(1998, 9, 16), new Email("exist@example.com")); + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup(loginId, "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.DUPLICATE_LOGIN_ID); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("loginId ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด repository๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void skipsRepositoryOnInvalidLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("test@user!", "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_LOGIN_ID); + verify(memberRepository, never()).findByLoginId(any()); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ์œ„๋ฐ˜์ด๋ฉด repository๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void skipsRepositoryOnInvalidPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberSignupService.signup("kwonmo", "short", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).findByLoginId(any()); + } + } +} From 337aba516ea3ddeb4c8a8931bee9bc4d9fddc6c6 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:08 +0900 Subject: [PATCH 06/19] =?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 000000000..f90f86e5a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberAuthService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberAuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public MemberModel authenticate(String loginId, String password) { + MemberModel member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.MEMBER_NOT_FOUND)); + + if (!member.matchesPassword(password, passwordEncoder)) { + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } + return member; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java new file mode 100644 index 000000000..7700520a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceIntegrationTest.java @@ -0,0 +1,79 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberAuthServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์› ์ธ์ฆ") + @Nested + class Authenticate { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ loginId์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMemberOnValidCredentials() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + MemberModel result = memberAuthService.authenticate("kwonmo", "Test1234!"); + + // then + assertThat(result.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ธ์ฆ ์‹คํŒจ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnWrongPassword() { + // given + memberSignupService.signup("kwonmo", "Test1234!", "์–‘๊ถŒ๋ชจ", + LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด MEMBER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnNonExistentLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java new file mode 100644 index 000000000..42ebd3a1a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberAuthServiceTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberAuthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberAuthService memberAuthService; + + @BeforeEach + void setUp() { + memberAuthService = new MemberAuthService(memberRepository, passwordEncoder); + } + + @DisplayName("ํšŒ์› ์ธ์ฆ") + @Nested + class Authenticate { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ์ž๊ฒฉ ์ฆ๋ช…์ด๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMemberOnValidCredentials() { + // given + String loginId = "kwonmo"; + String rawPassword = "Test1234!"; + MemberModel member = new MemberModel( + new LoginId(loginId), "encoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches(rawPassword, "encoded")).thenReturn(true); + + // when + MemberModel result = memberAuthService.authenticate(loginId, rawPassword); + + // then + assertThat(result.loginId().value()).isEqualTo(loginId); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด MEMBER_NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnNonExistentLoginId() { + // given + when(memberRepository.findByLoginId("nobody")).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("nobody", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.MEMBER_NOT_FOUND); + verify(passwordEncoder, never()).matches(any(), any()); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ธ์ฆ ์‹คํŒจ ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnWrongPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "encoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(memberRepository.findByLoginId("kwonmo")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("WrongPass1!", "encoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberAuthService.authenticate("kwonmo", "WrongPass1!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + } +} From d1159edf5fe5339cea4b198eb5de345e2e0a50bc Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:23 +0900 Subject: [PATCH 07/19] =?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 000000000..76e9d93f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberPasswordService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberPasswordService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void changePassword(MemberModel member, String currentPassword, String newRawPassword) { + if (!member.matchesPassword(currentPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH); + } + + Password newPassword = new Password(newRawPassword); + + if (member.matchesPassword(newRawPassword, passwordEncoder)) { + throw new CoreException(ErrorType.PASSWORD_SAME_AS_OLD); + } + newPassword.validateAgainst(member.birthDate()); + + member.changePassword(passwordEncoder.encode(newRawPassword)); + memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java new file mode 100644 index 000000000..f09cb43f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceIntegrationTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class MemberPasswordServiceIntegrationTest { + + @Autowired + private MemberSignupService memberSignupService; + + @Autowired + private MemberAuthService memberAuthService; + + @Autowired + private MemberPasswordService memberPasswordService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ") + @Nested + class ChangePassword { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฉด ๋ณ€๊ฒฝ์— ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void changesPasswordSuccessfully() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "์–‘๊ถŒ๋ชจ", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + memberPasswordService.changePassword(member, "Test1234!", "NewPass5678!"); + + // then + MemberModel updated = memberAuthService.authenticate("kwonmo", "NewPass5678!"); + assertThat(updated.loginId().value()).isEqualTo("kwonmo"); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "์–‘๊ถŒ๋ชจ", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ์™€ ๊ฐ™์œผ๋ฉด PASSWORD_SAME_AS_OLD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "์–‘๊ถŒ๋ชจ", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์— ๋งž์ง€ ์•Š์œผ๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "์–‘๊ถŒ๋ชจ", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ๊ฑฐ๋ถ€ํ•œ๋‹ค") + @Test + void rejectsNewPasswordContainingBirthDate() { + // given + MemberModel member = memberSignupService.signup("kwonmo", "Test1234!", + "์–‘๊ถŒ๋ชจ", LocalDate.of(1998, 9, 16), "kwonmo@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Pass19980916!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java new file mode 100644 index 000000000..04703f1ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberPasswordServiceTest.java @@ -0,0 +1,123 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberPasswordServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + private MemberPasswordService memberPasswordService; + + @BeforeEach + void setUp() { + memberPasswordService = new MemberPasswordService(memberRepository, passwordEncoder); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ") + @Nested + class ChangePassword { + + @DisplayName("์œ ํšจํ•œ ์š”์ฒญ์ด๋ฉด ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์•”ํ˜ธํ™”ํ•ด์„œ ์ €์žฅํ•œ๋‹ค") + @Test + void encodesAndSavesNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + when(passwordEncoder.matches("NewPass5678!", "currentEncoded")).thenReturn(false); + when(passwordEncoder.encode("NewPass5678!")).thenReturn("newEncoded"); + when(passwordEncoder.matches("NewPass5678!", "newEncoded")).thenReturn(true); + + // when + memberPasswordService.changePassword(member, "Current1234!", "NewPass5678!"); + + // then + assertThat(member.matchesPassword("NewPass5678!", passwordEncoder)).isTrue(); + verify(memberRepository).save(member); + verify(passwordEncoder).encode("NewPass5678!"); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnWrongCurrentPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("WrongPass1!", "currentEncoded")).thenReturn(false); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "WrongPass1!", "NewPass5678!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + verify(memberRepository, never()).save(any()); + verify(passwordEncoder, never()).encode(any()); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ์™€ ๊ฐ™์œผ๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void throwsWhenNewPasswordSameAsOld() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Test1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Test1234!", "Test1234!")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_SAME_AS_OLD); + verify(memberRepository, never()).save(any()); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์— ๋งž์ง€ ์•Š์œผ๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnInvalidNewPassword() { + // given + MemberModel member = new MemberModel( + new LoginId("kwonmo"), "currentEncoded", new MemberName("์–‘๊ถŒ๋ชจ"), + LocalDate.of(1998, 9, 16), new Email("kwonmo@example.com")); + + when(passwordEncoder.matches("Current1234!", "currentEncoded")).thenReturn(true); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberPasswordService.changePassword(member, "Current1234!", "short")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.INVALID_PASSWORD); + verify(memberRepository, never()).save(any()); + } + } +} From e3aa8944c61a2eef385463d1da334243df346d8e Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 5 Feb 2026 06:18:57 +0900 Subject: [PATCH 08/19] =?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 000000000..75658e7e8 --- /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 000000000..c20d1c9dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +import java.time.LocalDate; + +public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { + + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().value(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } + + public static MemberInfo fromWithMaskedName(MemberModel model) { + return new MemberInfo( + model.loginId().value(), + model.name().masked(), + model.birthDate(), + model.email() != null ? model.email().value() : null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..9cca8fd0b --- /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 000000000..a3ee3eb2f --- /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 000000000..9ae0821d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record MemberResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MemberResponse from(MemberInfo info) { + return new MemberResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 000000000..75353325b --- /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 000000000..b79abda30 --- /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 09/19] =?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 000000000..c426b1fcd --- /dev/null +++ b/apps/commerce-api/TEST-README.md @@ -0,0 +1,150 @@ +# Commerce API Test Checklist + +--- + +## Domain - Member + +### LoginIdTest (5) +- [ ] null, ๋นˆ ๋ฌธ์ž์—ด, ๊ณต๋ฐฑ์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค +- [ ] ํŠน์ˆ˜๋ฌธ์ž๋‚˜ ํ•œ๊ธ€์ด ํฌํ•จ๋˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค +- [ ] ์˜๋ฌธ, ์ˆซ์ž ์กฐํ•ฉ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] ๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค (equals) +- [ ] ๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค (equals) + +### PasswordTest (13) +- [ ] null์ด๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค +- [ ] ์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] 8~16์ž ๋ฒ”์œ„ ๋‚ด์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ—ˆ์šฉ๋œ๋‹ค +- [ ] 8~16์ž ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค +- [ ] ํ•œ๊ธ€์ด ํฌํ•จ๋˜๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค +- [ ] YYYYMMDD ํ˜•์‹์˜ ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] YYMMDD ํ˜•์‹์˜ ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜์–ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜์ง€ ์•Š์œผ๋ฉด ํ†ต๊ณผํ•œ๋‹ค +- [ ] birthDate๊ฐ€ null์ด๋ฉด ๊ฒ€์ฆ์„ ๊ฑด๋„ˆ๋›ด๋‹ค +- [ ] of ํŒฉํ† ๋ฆฌ: ์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒ๋…„์›”์ผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] of ํŒฉํ† ๋ฆฌ: ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] of ํŒฉํ† ๋ฆฌ: ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ฑฐ๋ถ€ํ•œ๋‹ค +- [ ] of ํŒฉํ† ๋ฆฌ: ์ƒ๋…„์›”์ผ์ด null์ด๋ฉด ํ˜•์‹๋งŒ ๊ฒ€์ฆํ•œ๋‹ค + +### MemberNameTest (5) +- [ ] null, ๋นˆ ๋ฌธ์ž์—ด, ๊ณต๋ฐฑ์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค +- [ ] ์œ ํšจํ•œ ์ด๋ฆ„์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] ๊ธ€์ž ์ˆ˜์— ๋”ฐ๋ผ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค (@CsvSource) +- [ ] ๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค (equals) +- [ ] ๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค (equals) + +### EmailTest (4) +- [ ] ์œ ํšจํ•œ ์ด๋ฉ”์ผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] ์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์œผ๋ฉด ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค +- [ ] ๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค (equals) +- [ ] ๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค (equals) + +### MemberModelTest (3) +- [ ] ์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] email์ด null์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋งค์นญ๋˜์ง€ ์•Š๋Š”๋‹ค + +### MemberRepositoryTest (3) - Integration +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด ๋นˆ Optional์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ์กด์žฌํ•˜๋Š” loginId๋ฉด ์ €์žฅ๋œ ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋ฉด ID๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค + +--- + +## Domain - Member Service (Unit) + +### MemberSignupServiceTest (4) +- [ ] ์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์•”ํ˜ธํ™”ํ•˜๊ณ  ์ €์žฅํ•œ๋‹ค +- [ ] ์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ loginId๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค +- [ ] loginId ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด repository๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค +- [ ] ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ์œ„๋ฐ˜์ด๋ฉด repository๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค + +### MemberAuthServiceTest (3) +- [ ] ์˜ฌ๋ฐ”๋ฅธ ์ž๊ฒฉ ์ฆ๋ช…์ด๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด MEMBER_NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค +- [ ] ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ธ์ฆ ์‹คํŒจ ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค + +### MemberPasswordServiceTest (4) +- [ ] ์œ ํšจํ•œ ์š”์ฒญ์ด๋ฉด ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์•”ํ˜ธํ™”ํ•ด์„œ ์ €์žฅํ•œ๋‹ค +- [ ] ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ์™€ ๊ฐ™์œผ๋ฉด ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์— ๋งž์ง€ ์•Š์œผ๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค + +--- + +## Domain - Member Service (Integration) + +### MemberSignupServiceIntegrationTest (5) +- [ ] ์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด ํšŒ์›์ด ์ƒ์„ฑ๋˜๊ณ  ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•”ํ˜ธํ™”๋œ๋‹ค +- [ ] ์ด๋ฏธ ์กด์žฌํ•˜๋Š” loginId๋กœ ๊ฐ€์ž…ํ•˜๋ฉด DUPLICATE_LOGIN_ID ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] loginId ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด INVALID_LOGIN_ID ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ฑฐ๋ถ€ํ•œ๋‹ค + +### MemberAuthServiceIntegrationTest (3) +- [ ] ์˜ฌ๋ฐ”๋ฅธ loginId์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด ์ธ์ฆ ์‹คํŒจ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” loginId๋ฉด MEMBER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค + +### MemberPasswordServiceIntegrationTest (5) +- [ ] ์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฉด ๋ณ€๊ฒฝ์— ์„ฑ๊ณตํ•œ๋‹ค +- [ ] ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ์™€ ๊ฐ™์œผ๋ฉด PASSWORD_SAME_AS_OLD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์— ๋งž์ง€ ์•Š์œผ๋ฉด INVALID_PASSWORD ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ๊ฑฐ๋ถ€ํ•œ๋‹ค + +--- + +## Application + +### MemberFacadeTest (3) +- [ ] ํšŒ์›๊ฐ€์ž…: SignupService์— ์œ„์ž„ํ•˜๊ณ  MemberInfo๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ: ์ธ์ฆ ํ›„ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ: ์ธ์ฆ ํ›„ PasswordService์— ์œ„์ž„ํ•œ๋‹ค + +--- + +## E2E (API) + +### MemberV1ApiE2ETest (8) +- [ ] POST /api/v1/members - ์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด 200๊ณผ ํšŒ์› ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] POST /api/v1/members - ์ค‘๋ณต loginId๋กœ ๊ฐ€์ž…ํ•˜๋ฉด 409๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] POST /api/v1/members - ์ž˜๋ชป๋œ loginId ํ˜•์‹์ด๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] GET /api/v1/members/me - ์ธ์ฆ ์„ฑ๊ณต ์‹œ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] GET /api/v1/members/me - ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด 401์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] PATCH /api/v1/members/me/password - ์œ ํšจํ•œ ์š”์ฒญ์ด๋ฉด 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] PATCH /api/v1/members/me/password - ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] PATCH /api/v1/members/me/password - ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค + +### ExampleV1ApiE2ETest (3) +- [ ] GET /api/v1/examples/{id} - ์กด์žฌํ•˜๋Š” ID๋ฉด ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] GET /api/v1/examples/{id} - ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] GET /api/v1/examples/{id} - ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค + +--- + +## Domain - Example + +### ExampleModelTest (3) +- [ ] ์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด ์ •์ƒ ์ƒ์„ฑ๋œ๋‹ค +- [ ] ์ œ๋ชฉ์ด ๊ณต๋ฐฑ์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค +- [ ] ์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค + +### ExampleServiceIntegrationTest (2) +- [ ] ์กด์žฌํ•˜๋Š” ID๋ฉด ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค + +--- + +## Support + +### CoreExceptionTest (2) +- [ ] ์ปค์Šคํ…€ ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์œผ๋ฉด ErrorType์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค +- [ ] ์ปค์Šคํ…€ ๋ฉ”์‹œ์ง€๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ํ•ด๋‹น ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค + +--- + +## Context + +### CommerceApiContextTest (1) +- [ ] Spring Boot ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปจํ…์ŠคํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ๋กœ๋“œ๋œ๋‹ค diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java index 44ca7576e..5a94cb896 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class ExampleModelTest { - @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ ์ƒ์„ฑ") @Nested class Create { - @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด ์ •์ƒ ์ƒ์„ฑ๋œ๋‹ค") @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { + void createsWithNameAndDescription() { // arrange String name = "์ œ๋ชฉ"; String description = "์„ค๋ช…"; @@ -32,9 +32,9 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { ); } - @DisplayName("์ œ๋ชฉ์ด ๋นˆ์นธ์œผ๋กœ๋งŒ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @DisplayName("์ œ๋ชฉ์ด ๊ณต๋ฐฑ์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") @Test - void throwsBadRequestException_whenTitleIsBlank() { + void throwsOnBlankTitle() { // arrange String name = " "; @@ -47,9 +47,9 @@ void throwsBadRequestException_whenTitleIsBlank() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { + void throwsOnEmptyDescription() { // arrange String description = ""; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java index bbd5fdbe1..7a74d1076 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -31,12 +31,12 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @DisplayName("์˜ˆ์‹œ ์กฐํšŒ") @Nested class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฉด ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test - void returnsExampleInfo_whenValidIdIsProvided() { + void returnsExampleForExistingId() { // arrange ExampleModel exampleModel = exampleJpaRepository.save( new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") @@ -54,11 +54,11 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") @Test - void throwsException_whenInvalidIdIsProvided() { + void throwsOnNonExistentId() { // arrange - Long invalidId = 999L; // Assuming this ID does not exist + Long invalidId = 999L; // act CoreException exception = assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java index 1bb3dba65..bee78db56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -51,7 +51,7 @@ void tearDown() { @DisplayName("GET /api/v1/examples/{id}") @Nested class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฉด ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test void returnsExampleInfo_whenValidIdIsProvided() { // arrange @@ -74,7 +74,7 @@ void returnsExampleInfo_whenValidIdIsProvided() { ); } - @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID ๋กœ ์š”์ฒญํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test void throwsBadRequest_whenIdIsNotProvided() { // arrange @@ -92,7 +92,7 @@ void throwsBadRequest_whenIdIsNotProvided() { ); } - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, 404 NOT_FOUND ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test void throwsException_whenInvalidIdIsProvided() { // arrange diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e6..aff2274cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -6,9 +6,9 @@ import static org.assertj.core.api.Assertions.assertThat; class CoreExceptionTest { - @DisplayName("ErrorType ๊ธฐ๋ฐ˜์˜ ์˜ˆ์™ธ ์ƒ์„ฑ ์‹œ, ๋ณ„๋„์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ์ฃผ์–ด์ง€์ง€ ์•Š์œผ๋ฉด ErrorType์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.") + @DisplayName("์ปค์Šคํ…€ ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์œผ๋ฉด ErrorType์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค") @Test - void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + void usesErrorTypeMessageByDefault() { // arrange ErrorType[] errorTypes = ErrorType.values(); @@ -19,9 +19,9 @@ void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { } } - @DisplayName("ErrorType ๊ธฐ๋ฐ˜์˜ ์˜ˆ์™ธ ์ƒ์„ฑ ์‹œ, ๋ณ„๋„์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ํ•ด๋‹น ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.") + @DisplayName("์ปค์Šคํ…€ ๋ฉ”์‹œ์ง€๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ํ•ด๋‹น ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค") @Test - void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + void usesCustomMessageWhenProvided() { // arrange String customMessage = "custom message"; From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 22bb7c9b033e348500325483bf55d38c0c269053 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 13 Feb 2026 17:08:50 +0900 Subject: [PATCH 11/19] =?UTF-8?q?docs:=20=EC=9D=B4=EC=BB=A4=EB=A8=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1=20-=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9A=94=EA=B5=AC=EC=A0=95=EC=9D=98=EC=84=9C=20-=20?= =?UTF-8?q?=EC=9C=A0=EB=B9=84=EC=BF=BC=ED=84=B0=EC=8A=A4=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=EC=A0=95=EC=9D=98=EC=84=9C=20-=20=EC=8B=9C?= =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20-=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20-=20erd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/mermaid/01-requirements.md | 335 ++++++++++++++++++ docs/design/mermaid/02-ubiquitous-language.md | 110 ++++++ .../mermaid/03-sequence-brand-delete.mmd | 23 ++ .../mermaid/03-sequence-like-toggle.mmd | 47 +++ .../mermaid/03-sequence-order-creation.mmd | 31 ++ .../mermaid/03-sequence-product-list.mmd | 28 ++ docs/design/mermaid/04-class-diagram.mmd | 98 +++++ docs/design/mermaid/05-erd.mmd | 59 +++ 8 files changed, 731 insertions(+) create mode 100644 docs/design/mermaid/01-requirements.md create mode 100644 docs/design/mermaid/02-ubiquitous-language.md create mode 100644 docs/design/mermaid/03-sequence-brand-delete.mmd create mode 100644 docs/design/mermaid/03-sequence-like-toggle.mmd create mode 100644 docs/design/mermaid/03-sequence-order-creation.mmd create mode 100644 docs/design/mermaid/03-sequence-product-list.mmd create mode 100644 docs/design/mermaid/04-class-diagram.mmd create mode 100644 docs/design/mermaid/05-erd.mmd diff --git a/docs/design/mermaid/01-requirements.md b/docs/design/mermaid/01-requirements.md new file mode 100644 index 000000000..0a7cd3bae --- /dev/null +++ b/docs/design/mermaid/01-requirements.md @@ -0,0 +1,335 @@ +# ์ด์ปค๋จธ์Šค ์š”๊ตฌ์‚ฌํ•ญ ์ •์˜์„œ + +--- + +## 1. Brand (๋ธŒ๋žœ๋“œ) + +### ์œ ์ € ์Šคํ† ๋ฆฌ + +- ๊ณ ๊ฐ์€ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ์ƒˆ๋กœ์šด ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ๋ธŒ๋žœ๋“œ ์ •๋ณด(์ด๋ฆ„, ์„ค๋ช…)๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ๋ธŒ๋žœ๋“œ๋ฅผ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. + +### ๊ธฐ๋Šฅ ํ๋ฆ„ + +**๋ธŒ๋žœ๋“œ ๋“ฑ๋ก** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ (@AdminUser) +2. ๋ธŒ๋žœ๋“œ๋ช… ์œ ํšจ์„ฑ ๊ฒ€์ฆ (๋นˆ๊ฐ’ ๋ถˆ๊ฐ€) +3. ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต ์ฒดํฌ (Q17: ์ค‘๋ณต ๋ถˆ๊ฐ€ โ†’ 409 Conflict) +4. ๋ธŒ๋žœ๋“œ ์ƒ์„ฑ ๋ฐ ์ €์žฅ +5. ๋“ฑ๋ก ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + +**๋ธŒ๋žœ๋“œ ์ˆ˜์ •** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ +2. ๋ธŒ๋žœ๋“œ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ (์—†์œผ๋ฉด 404) +3. ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์ค‘๋ณต ์ฒดํฌ +4. name, description ์ˆ˜์ • ๋ฐ ์ €์žฅ +5. ์†Œ์† ์ƒํ’ˆ์—๋Š” ๋ณ„๋„ ์—…๋ฐ์ดํŠธ ๋ถˆํ•„์š” (Q31: brandId ์ฐธ์กฐ ๋ฐฉ์‹์œผ๋กœ ์ž๋™ ๋ฐ˜์˜) + +**๋ธŒ๋žœ๋“œ ์‚ญ์ œ (์—ฐ์‡„)** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ +2. ๋ธŒ๋žœ๋“œ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ (์—†์œผ๋ฉด 404) +3. ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์†Œ์† ์ƒํ’ˆ ์ „์ฒด soft delete (Q1: Soft Delete ์—ฐ์‡„) +4. ๋ธŒ๋žœ๋“œ soft delete +5. ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ (์ „์ฒด ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜) + +### ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + +- ๋ธŒ๋žœ๋“œ๋ช…์€ ์œ ๋‹ˆํฌํ•ด์•ผ ํ•œ๋‹ค (Q17) +- ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ๋„ ํ•จ๊ป˜ soft delete (Q1) +- ๋ธŒ๋žœ๋“œ-์ƒํ’ˆ์€ ID ์ฐธ์กฐ๋งŒ ์‚ฌ์šฉ, JPA ์—ฐ๊ด€๊ด€๊ณ„ ์—†์Œ (ADR-008) +- ๋ธŒ๋žœ๋“œ restore๋Š” ์„ค๊ณ„ ๋ฒ”์œ„์—์„œ ์ œ์™ธ (Q2) +- ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ์ƒˆ ์ƒํ’ˆ ๋“ฑ๋ก ๋ถˆ๊ฐ€ (Q27) +- ๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • ์‹œ ์†Œ์† ์ƒํ’ˆ์— ์ž๋™ ๋ฐ˜์˜๋จ โ€” brandId ์ฐธ์กฐ ๋ฐฉ์‹ (Q31) +- ๋ธŒ๋žœ๋“œ ํ•„๋“œ: name(ํ•„์ˆ˜, BrandName VO), description(์„ ํƒ) (Q14) + +### API + +| Method | Endpoint | ์„ค๋ช… | ์ธ์ฆ | +|--------|----------|------|------| +| GET | `/api/v1/brands/{brandId}` | ๊ณ ๊ฐ ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ | ๋ถˆํ•„์š” | +| POST | `/api-admin/v1/brands` | ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก | @AdminUser | +| GET | `/api-admin/v1/brands?page=0&size=20` | ์–ด๋“œ๋ฏผ ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก (์‚ญ์ œ ํฌํ•จ) | @AdminUser | +| GET | `/api-admin/v1/brands/{brandId}` | ์–ด๋“œ๋ฏผ ๋ธŒ๋žœ๋“œ ์ƒ์„ธ | @AdminUser | +| PUT | `/api-admin/v1/brands/{brandId}` | ๋ธŒ๋žœ๋“œ ์ˆ˜์ • | @AdminUser | +| DELETE | `/api-admin/v1/brands/{brandId}` | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ | @AdminUser | + +--- + +## 2. Product + Stock (์ƒํ’ˆ + ์žฌ๊ณ ) + +### ์œ ์ € ์Šคํ† ๋ฆฌ + +- ๊ณ ๊ฐ์€ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. +- ๊ณ ๊ฐ์€ ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ์ƒˆ๋กœ์šด ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ์ƒํ’ˆ์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. + +### ๊ธฐ๋Šฅ ํ๋ฆ„ + +**์ƒํ’ˆ ๋“ฑ๋ก** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ (@AdminUser) +2. ๋ธŒ๋žœ๋“œ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ (์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ ๋ถˆ๊ฐ€, Q27) +3. ์ƒํ’ˆ ์ •๋ณด ๊ฒ€์ฆ (name ํ•„์ˆ˜, price >= 0) +4. ์ƒํ’ˆ ์ƒ์„ฑ ๋ฐ ์ €์žฅ +5. Stock ์ƒ์„ฑ (initialStock ์ˆ˜๋Ÿ‰, Product์™€ 1:1, Q4) +6. ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ + +**์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Customer)** + +1. ์ •๋ ฌ ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ (Q24: created_desc / price_asc / price_desc / likes_desc) +2. ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ (Q15: Spring Pageable) +3. ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ œ์™ธ (deletedAt IS NULL) +4. ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ ์šฉ (์„ ํƒ) +5. Stock ์ •๋ณด ์กฐํ•ฉ +6. ์žฌ๊ณ  ์ƒํƒœ ๋ณ€ํ™˜ (Q6: >10 โ†’ IN_STOCK, 1~10 โ†’ LOW_STOCK, 0 โ†’ OUT_OF_STOCK) + +**์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Admin)** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ +2. ์‚ญ์ œ๋œ ์ƒํ’ˆ๋„ ํฌํ•จํ•˜์—ฌ ์กฐํšŒ ๊ฐ€๋Šฅ +3. ์ •ํ™•ํ•œ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ํ‘œ์‹œ (Q6) +4. createdAt, updatedAt, deletedAt ํฌํ•จ + +**์ƒํ’ˆ ์ˆ˜์ • (Admin)** + +1. ์–ด๋“œ๋ฏผ ์ธ์ฆ ํ™•์ธ +2. ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ +3. ์ˆ˜์ • ๊ฐ€๋Šฅ ํ•„๋“œ๋งŒ ๋ณ€๊ฒฝ: name, description, price (Q29) +4. ์ˆ˜์ • ๋ถˆ๊ฐ€ ํ•„๋“œ: brandId(๋ธŒ๋žœ๋“œ ์ด๋™ ๋ถˆ๊ฐ€), likeCount(์‹œ์Šคํ…œ ๊ด€๋ฆฌ) (Q29) +5. ์ €์žฅ + +### ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + +- ์ƒํ’ˆ ํ•„๋“œ: name, description, price(Money VO), brandId ํ•„์ˆ˜ (Q3) +- ์žฌ๊ณ ๋Š” ๋ณ„๋„ Stock ์—”ํ‹ฐํ‹ฐ๋กœ ๋ถ„๋ฆฌ โ€” Product์™€ 1:1 (Q4) +- ๊ฐ€๊ฒฉ์€ Money VO (int ๋‚ด๋ถ€ ํƒ€์ž…, ์›ํ™”, ์Œ์ˆ˜ ๋ถˆ๊ฐ€, 0์› ํ—ˆ์šฉ) (Q5) +- ๊ณ ๊ฐ์—๊ฒŒ๋Š” ์žฌ๊ณ  ์ƒํƒœ(IN_STOCK/LOW_STOCK/OUT_OF_STOCK)๋งŒ ํ‘œ์‹œ (Q6) +- ์–ด๋“œ๋ฏผ์—๊ฒŒ๋Š” ์ •ํ™•ํ•œ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ํ‘œ์‹œ (Q6) +- ์ƒํ’ˆ๋ช… ์ค‘๋ณต ํ—ˆ์šฉ (Q18) +- ์ˆ˜์ • ๊ฐ€๋Šฅ: name, description, price / ์ˆ˜์ • ๋ถˆ๊ฐ€: brandId, likeCount (Q29) +- ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ์ƒํ’ˆ ๋“ฑ๋ก ๋ถˆ๊ฐ€ (Q27) + +### ์ •๋ ฌ ์˜ต์…˜ (Q24) + +| ์ •๋ ฌ ๊ฐ’ | ๋™์ž‘ | +|---------|------| +| `created_desc` (๊ธฐ๋ณธ) | ์ƒํ’ˆ ๋“ฑ๋ก์ผ ์ตœ์‹ ์ˆœ | +| `price_asc` | ๊ฐ€๊ฒฉ ๋‚ฎ์€์ˆœ | +| `price_desc` | ๊ฐ€๊ฒฉ ๋†’์€์ˆœ | +| `likes_desc` | ์ข‹์•„์š” ๋งŽ์€์ˆœ | + +### API + +| Method | Endpoint | ์„ค๋ช… | ์ธ์ฆ | +|--------|----------|------|------| +| GET | `/api/v1/products?brandId={brandId}&sort={sort}&page=0&size=20` | ๊ณ ๊ฐ ์ƒํ’ˆ ๋ชฉ๋ก | ๋ถˆํ•„์š” | +| GET | `/api/v1/products/{productId}` | ๊ณ ๊ฐ ์ƒํ’ˆ ์ƒ์„ธ | ๋ถˆํ•„์š” | +| POST | `/api-admin/v1/products` | ์ƒํ’ˆ ๋“ฑ๋ก (initialStock ํฌํ•จ) | @AdminUser | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | ์–ด๋“œ๋ฏผ ์ƒํ’ˆ ๋ชฉ๋ก (์‚ญ์ œ ํฌํ•จ) | @AdminUser | +| GET | `/api-admin/v1/products/{productId}` | ์–ด๋“œ๋ฏผ ์ƒํ’ˆ ์ƒ์„ธ | @AdminUser | +| PUT | `/api-admin/v1/products/{productId}` | ์ƒํ’ˆ ์ˆ˜์ • | @AdminUser | +| DELETE | `/api-admin/v1/products/{productId}` | ์ƒํ’ˆ ์‚ญ์ œ | @AdminUser | + +--- + +## 3. Like (์ข‹์•„์š”) + +### ์œ ์ € ์Šคํ† ๋ฆฌ + +- ์‚ฌ์šฉ์ž๋Š” ์ƒํ’ˆ์„ ์ฐœํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์ด๋ฏธ ์ฐœํ•œ ์ƒํ’ˆ์„ ๋‹ค์‹œ ๋ˆ„๋ฅด๋ฉด ๋ฌด์‹œ๋œ๋‹ค (๋ฉฑ๋“ฑ์„ฑ). +- ์‚ฌ์šฉ์ž๋Š” ์ฐœ์„ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์ด๋ฏธ ์ทจ์†Œํ•œ ์ฐœ์„ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ๋ฌด์‹œ๋œ๋‹ค (๋ฉฑ๋“ฑ์„ฑ). +- ์‚ฌ์šฉ์ž๋Š” ์ฐœํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. + +### ๊ธฐ๋Šฅ ํ๋ฆ„ + +**์ข‹์•„์š” ์ถ”๊ฐ€** + +1. ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋งŒ ๊ฐ€๋Šฅ (@LoginMember) +2. ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ (์‚ญ์ œ๋œ ์ƒํ’ˆ ๋ถˆ๊ฐ€, Q16) +3. ๊ธฐ์กด ์ข‹์•„์š” ์กด์žฌ ์—ฌ๋ถ€ ํŒ๋‹จ +4. ์—†์œผ๋ฉด ์ข‹์•„์š” ์ €์žฅ + likeCount ์ฆ๊ฐ€ +5. ์žˆ์œผ๋ฉด ์•„๋ฌด ๋™์ž‘ ์—†์ด 200 OK ๋ฐ˜ํ™˜ (Q7: ๋ฉฑ๋“ฑ์„ฑ) + +**์ข‹์•„์š” ์ทจ์†Œ** + +1. ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋งŒ ๊ฐ€๋Šฅ +2. ๊ธฐ์กด ์ข‹์•„์š” ์กด์žฌ ์—ฌ๋ถ€ ํŒ๋‹จ +3. ์žˆ์œผ๋ฉด ์‚ญ์ œ + likeCount ๊ฐ์†Œ (์Œ์ˆ˜ ๋ฐฉ์ง€, Q28) +4. ์—†์œผ๋ฉด ์•„๋ฌด ๋™์ž‘ ์—†์ด 200 OK ๋ฐ˜ํ™˜ (Q7: ๋ฉฑ๋“ฑ์„ฑ) + +**์ข‹์•„์š” ๋ชฉ๋ก ์กฐํšŒ** + +1. ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋งŒ ๊ฐ€๋Šฅ +2. ๋ณธ์ธ์˜ ์ข‹์•„์š” ๋ชฉ๋ก๋งŒ ์กฐํšŒ +3. ์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ๋ชฉ๋ก์—์„œ ์ œ์™ธ (Q16) +4. ์ข‹์•„์š” ๋ˆ„๋ฅธ ์‹œ๊ฐ„ ์ตœ์‹ ์ˆœ ์ •๋ ฌ (Q23) +5. ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ + +### ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + +- ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ๋ชจ๋‘ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ, 200 OK ๋ฐ˜ํ™˜ (Q7) +- Product ํ…Œ์ด๋ธ”์— likeCount ๋น„์ •๊ทœํ™” ์œ ์ง€ (Q8) +- likeCount๋Š” 0 ๋ฏธ๋งŒ์ด ๋  ์ˆ˜ ์—†๋‹ค โ€” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ฐ€๋“œ + DB CHECK (Q28) +- ์‚ญ์ œ๋œ ์ƒํ’ˆ์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ (Q16) +- ์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ์ข‹์•„์š” ๋ชฉ๋ก์—์„œ ์ œ์™ธ (Q16) +- ์ข‹์•„์š” ๋ชฉ๋ก์€ ์ข‹์•„์š” ๋ˆ„๋ฅธ ์‹œ๊ฐ„ ์ตœ์‹ ์ˆœ (Q23) +- ์ข‹์•„์š” ๋ฐ์ดํ„ฐ ์ž์ฒด๋Š” ์‚ญ์ œํ•˜์ง€ ์•Š์Œ (์ƒํ’ˆ ๋ณต๊ตฌ ์‹œ ๋‹ค์‹œ ๋ณด์ž„) + +### API + +| Method | Endpoint | ์„ค๋ช… | ์ธ์ฆ | +|--------|----------|------|------| +| POST | `/api/v1/products/{productId}/likes` | ์ข‹์•„์š” ์ถ”๊ฐ€ | @LoginMember | +| DELETE | `/api/v1/products/{productId}/likes` | ์ข‹์•„์š” ์ทจ์†Œ | @LoginMember | +| GET | `/api/v1/users/{userId}/likes` | ๋‚ด ์ข‹์•„์š” ๋ชฉ๋ก | @LoginMember | + +--- + +## 4. Order (์ฃผ๋ฌธ) + +### ์œ ์ € ์Šคํ† ๋ฆฌ + +- ๊ณ ๊ฐ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์ฃผ๋ฌธํ•  ์ˆ˜ ์žˆ๋‹ค. +- ์ฃผ๋ฌธํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ์ฆ‰์‹œ ์ฐจ๊ฐ๋œ๋‹ค. +- ์ฃผ๋ฌธ ์ •๋ณด์—๋Š” ๋‹น์‹œ์˜ ์ƒํ’ˆ ์ •๋ณด๊ฐ€ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ๋œ๋‹ค. +- ๊ณ ๊ฐ์€ ์ž์‹ ์˜ ์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. +- ๊ณ ๊ฐ์€ ์ฃผ๋ฌธ ์ƒ์„ธ(์ƒํ’ˆ๋ณ„ ์ˆ˜๋Ÿ‰, ๊ธˆ์•ก)๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. +- ์–ด๋“œ๋ฏผ์€ ์ „์ฒด ์ฃผ๋ฌธ์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. + +### ๊ธฐ๋Šฅ ํ๋ฆ„ + +**์ฃผ๋ฌธ ์ƒ์„ฑ** + +1. ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋งŒ ๊ฐ€๋Šฅ (@LoginMember) +2. ๊ฐ ์ฃผ๋ฌธ ์ƒํ’ˆ์— ๋Œ€ํ•ด: + a. ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ โ€” ์‚ญ์ œ๋œ ์ƒํ’ˆ ํฌํ•จ ์‹œ ์ „์ฒด ์‹คํŒจ (Q20) + b. ์ˆ˜๋Ÿ‰ ๊ฒ€์ฆ โ€” 1 ์ด์ƒ (Q26) + c. ์žฌ๊ณ  ํ™•์ธ ๋ฐ ์ฐจ๊ฐ โ€” ๋ถ€์กฑ ์‹œ ์ „์ฒด ์‹คํŒจ (Q19) +3. Order ์ƒ์„ฑ (status = CREATED, Q11) +4. OrderItem ์ƒ์„ฑ โ€” ์ƒํ’ˆ๋ช…, ๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท ์ €์žฅ (Q9) +5. ์ด์•ก์€ ์„œ๋ฒ„์—์„œ ๊ณ„์‚ฐํ•˜์—ฌ Order์— ์ €์žฅ (Q12) +6. ์ „์ฒด ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ โ€” ์‹คํŒจ ์‹œ ์ „์ฒด ๋กค๋ฐฑ (Q19: All or Nothing) + +**์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ** + +1. ๊ณ ๊ฐ: ๋ณธ์ธ ์ฃผ๋ฌธ๋งŒ ์กฐํšŒ (Q25: userId ํ•„ํ„ฐ) +2. ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ ์ ์šฉ (startAt, endAt ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ) +3. ์–ด๋“œ๋ฏผ: ์ „์ฒด ์ฃผ๋ฌธ ์กฐํšŒ ๊ฐ€๋Šฅ (Q25) +4. ์ตœ์‹ ์ˆœ ์ •๋ ฌ, ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ + +**์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ** + +1. ๊ณ ๊ฐ: ๋ณธ์ธ ์ฃผ๋ฌธ๋งŒ ์ƒ์„ธ ์กฐํšŒ ๊ฐ€๋Šฅ +2. OrderItem ๋ชฉ๋ก ํฌํ•จ (์Šค๋ƒ…์ƒท๋œ ์ƒํ’ˆ๋ช…, ๊ฐ€๊ฒฉ, ์ˆ˜๋Ÿ‰, ์†Œ๊ณ„) +3. ์ƒํ’ˆ์ด ์‚ญ์ œ๋œ ํ›„์—๋„ ์Šค๋ƒ…์ƒท์œผ๋กœ ์กฐํšŒ ๊ฐ€๋Šฅ + +### ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + +- Order + OrderItem ๋ถ„๋ฆฌ (Q10: 1:N ๊ด€๊ณ„) +- OrderItem์— ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ๋ช…, ๊ฐ€๊ฒฉ, ์ˆ˜๋Ÿ‰ ์Šค๋ƒ…์ƒท ์ €์žฅ (Q9) +- OrderStatus: CREATED๋งŒ ์‚ฌ์šฉ, ๋‚˜๋จธ์ง€(CONFIRMED, SHIPPING, DELIVERED, CANCELLED)๋Š” ๋ฏธ๋ž˜ ํ™•์žฅ์šฉ (Q11) +- ์ด์•ก์€ ์„œ๋ฒ„์—์„œ ๊ณ„์‚ฐ, ํด๋ผ์ด์–ธํŠธ ์ „์†ก๊ฐ’ ๋ฌด์‹œ (Q12) +- ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์ฃผ๋ฌธ ์ „์ฒด ์‹คํŒจ โ€” All or Nothing (Q19) +- ์‚ญ์ œ๋œ ์ƒํ’ˆ ํฌํ•จ ์‹œ ์ „์ฒด ์‹คํŒจ + ์ƒ์„ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ (Q20) +- ์ฃผ๋ฌธ ์ƒ์„ฑ๊ณผ ๋™์‹œ์— ์žฌ๊ณ  ์ฐจ๊ฐ, ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ (Q21) +- ์ž๊ธฐ ์ž์‹  ์ฃผ๋ฌธ ์ œํ•œ ์—†์Œ (Q22: ์–ด๋“œ๋ฏผ/๊ณ ๊ฐ ์ธ์ฆ ์ฒด๊ณ„ ๋ถ„๋ฆฌ) +- ๊ณ ๊ฐ์€ ๋ณธ์ธ ์ฃผ๋ฌธ๋งŒ, ์–ด๋“œ๋ฏผ์€ ์ „์ฒด ์กฐํšŒ ๊ฐ€๋Šฅ (Q25) +- ์ˆ˜๋Ÿ‰์€ ์ตœ์†Œ 1, ์ตœ๋Œ€ ํ•ด๋‹น ์ƒํ’ˆ ์žฌ๊ณ  ์ดํ•˜ (Q26) + +### API + +| Method | Endpoint | ์„ค๋ช… | ์ธ์ฆ | +|--------|----------|------|------| +| POST | `/api/v1/orders` | ์ฃผ๋ฌธ ์ƒ์„ฑ | @LoginMember | +| GET | `/api/v1/orders?startAt={date}&endAt={date}` | ๋‚ด ์ฃผ๋ฌธ ๋ชฉ๋ก (๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ) | @LoginMember | +| GET | `/api/v1/orders/{orderId}` | ์ฃผ๋ฌธ ์ƒ์„ธ | @LoginMember | +| GET | `/api-admin/v1/orders?page=0&size=20` | ์ „์ฒด ์ฃผ๋ฌธ ๋ชฉ๋ก | @AdminUser | +| GET | `/api-admin/v1/orders/{orderId}` | ์ฃผ๋ฌธ ์ƒ์„ธ | @AdminUser | + +--- + +## 5. ๊ณตํ†ต + +### ์ธ์ฆ (Q13: ArgumentResolver) + +- `@LoginMember` โ€” ๊ณ ๊ฐ ์ธ์ฆ (X-Loopers-LoginId + X-Loopers-LoginPw โ†’ MemberModel ์ฃผ์ž…) +- `@AdminUser` โ€” ์–ด๋“œ๋ฏผ ์ธ์ฆ (X-Loopers-Ldap โ†’ AdminInfo ์ฃผ์ž…) + +### ํŽ˜์ด์ง€๋„ค์ด์…˜ (Q15) + +- Spring Pageable ์‚ฌ์šฉ +- ๊ณตํ†ต ํŒŒ๋ผ๋ฏธํ„ฐ: page, size +- JPA Repository์™€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์—ฐ๋™ + +### ์—๋Ÿฌ ์ฒ˜๋ฆฌ (Q32) + +๊ธฐ์กด ErrorType 4์ข…(NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR)์„ ํ™œ์šฉํ•˜๊ณ , ๋ฉ”์‹œ์ง€๋กœ ์ƒํ™ฉ์„ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +| ์ƒํ™ฉ | ErrorType | ๋ฉ”์‹œ์ง€ ์˜ˆ์‹œ | +|------|-----------|------------| +| ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ/์ฃผ๋ฌธ | NOT_FOUND | "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" | +| ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต | CONFLICT | "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ช…์ž…๋‹ˆ๋‹ค" | +| ์žฌ๊ณ  ๋ถ€์กฑ | BAD_REQUEST | "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค: [์ƒํ’ˆ๋ช…]" | +| ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ 0 ์ดํ•˜ | BAD_REQUEST | "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" | +| ์‚ญ์ œ๋œ ์ƒํ’ˆ์— ์ข‹์•„์š” | NOT_FOUND | "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" | +| ์‚ญ์ œ๋œ ์ƒํ’ˆ์ด ์ฃผ๋ฌธ์— ํฌํ•จ | NOT_FOUND | "์‚ญ์ œ๋œ ์ƒํ’ˆ์ด ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค: [์ƒํ’ˆ๋ช…]" | + +--- + +## 6. API ์—”๋“œํฌ์ธํŠธ ์š”์•ฝ + +| ๊ตฌ๋ถ„ | Customer | Admin | ํ•ฉ๊ณ„ | +|------|----------|-------|------| +| Brand | 1 | 5 | 6 | +| Product | 2 | 5 | 7 | +| Like | 3 | 0 | 3 | +| Order | 3 | 2 | 5 | +| **ํ•ฉ๊ณ„** | **9** | **12** | **21** | + +--- + +## 7. Q&A ํŠธ๋ ˆ์ด๋“œ์˜คํ”„ ์ถ”์ ํ‘œ + +| Q# | ๊ฒฐ์ • ์‚ฌํ•ญ | ๋ฐ˜์˜ ์œ„์น˜ | +|----|-----------|-----------| +| Q1 | Soft Delete ์—ฐ์‡„ | Brand ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™, ๊ธฐ๋Šฅ ํ๋ฆ„ | +| Q2 | ๋ธŒ๋žœ๋“œ restore ์ œ์™ธ | Brand ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q3 | ์ƒํ’ˆ ํ•„๋“œ (name, desc, price, brandId) | Product ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q4 | Stock ๋ณ„๋„ ์—”ํ‹ฐํ‹ฐ ๋ถ„๋ฆฌ | Product ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™, ๊ธฐ๋Šฅ ํ๋ฆ„ | +| Q5 | Money VO (int) | Product ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q6 | ๊ณ ๊ฐ/์–ด๋“œ๋ฏผ ์žฌ๊ณ  ํ‘œ์‹œ ์ฐจ์ด | Product ๊ธฐ๋Šฅ ํ๋ฆ„ | +| Q7 | ์ข‹์•„์š” ๋ฉฑ๋“ฑ์„ฑ | Like ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q8 | likeCount ๋น„์ •๊ทœํ™” | Like ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q9 | ์Šค๋ƒ…์ƒท (์ƒํ’ˆ๋ช… + ๊ฐ€๊ฒฉ + ์ˆ˜๋Ÿ‰) | Order ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q10 | Order + OrderItem ๋ถ„๋ฆฌ | Order ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q11 | OrderStatus enum ๋ฏธ๋ž˜ ํ™•์žฅ | Order ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q12 | ์„œ๋ฒ„ ๊ณ„์‚ฐ totalAmount | Order ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q13 | ArgumentResolver | ๊ณตํ†ต ์ธ์ฆ | +| Q14 | ๋ธŒ๋žœ๋“œ ํ•„๋“œ (name + description) | Brand ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q15 | Spring Pageable | ๊ณตํ†ต ํŽ˜์ด์ง€๋„ค์ด์…˜ | +| Q16 | ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ข‹์•„์š” ๋ถˆ๊ฐ€ + ๋ชฉ๋ก ์ œ์™ธ | Like ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q17 | ๋ธŒ๋žœ๋“œ๋ช… ์ค‘๋ณต ๋ถˆ๊ฐ€ | Brand ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q18 | ์ƒํ’ˆ๋ช… ์ค‘๋ณต ํ—ˆ์šฉ | Product ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q19 | ์žฌ๊ณ  ๋ถ€์กฑ ์ „์ฒด ์‹คํŒจ | Order ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q20 | ์‚ญ์ œ ์ƒํ’ˆ ํฌํ•จ ์‹œ ์ „์ฒด ์‹คํŒจ | Order ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q21 | ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œ ์žฌ๊ณ  ์ฆ‰์‹œ ์ฐจ๊ฐ | Order ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q22 | ์ž๊ธฐ ์ž์‹  ์ฃผ๋ฌธ ์ œํ•œ ์—†์Œ | Order ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q23 | ์ข‹์•„์š” ๋ชฉ๋ก ์ตœ์‹ ์ˆœ | Like ๊ธฐ๋Šฅ ํ๋ฆ„ | +| Q24 | ์ปค์Šคํ…€ sort 4์ข… | Product ์ •๋ ฌ ์˜ต์…˜ | +| Q25 | ์ฃผ๋ฌธ ์กฐํšŒ ๋ฒ”์œ„ (๊ณ ๊ฐ: ๋ณธ์ธ, ์–ด๋“œ๋ฏผ: ์ „์ฒด) | Order ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q26 | ์ˆ˜๋Ÿ‰ ์ตœ์†Œ 1, ์ตœ๋Œ€ ์žฌ๊ณ  ์ดํ•˜ | Order ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q27 | ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ์ƒํ’ˆ ๋“ฑ๋ก ๋ถˆ๊ฐ€ | Brand/Product ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q28 | likeCount ์Œ์ˆ˜ ๋ฐฉ์ง€ | Like ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q29 | ์ƒํ’ˆ ์ˆ˜์ • ๋ฒ”์œ„ (name, desc, price๋งŒ) | Product ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q30 | ~~์žฌ๊ณ  ์ ˆ๋Œ€๊ฐ’ ์„ธํŒ…~~ (๊ตฌํ˜„ ๋ฒ”์œ„ ์ œ์™ธ) | - | +| Q31 | ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์ž๋™ ๋ฐ˜์˜ | Brand ๊ธฐ๋Šฅ ํ๋ฆ„, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | +| Q32 | ๊ธฐ์กด ErrorType 4์ข… ํ™œ์šฉ | ๊ณตํ†ต ์—๋Ÿฌ ์ฒ˜๋ฆฌ | diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md new file mode 100644 index 000000000..c7138535e --- /dev/null +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -0,0 +1,110 @@ +# ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด + +ํ”„๋กœ์ ํŠธ ์ „๋ฐ˜์—์„œ ํ†ต์ผํ•˜์—ฌ ์‚ฌ์šฉํ•˜๋Š” ๋„๋ฉ”์ธ ์šฉ์–ด๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. +์ฝ”๋“œ, ๋ฌธ์„œ, ์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜์—์„œ ๋™์ผํ•œ ์˜๋ฏธ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +--- + +## 1. Actor (ํ–‰์œ„์ž) + +| ์šฉ์–ด | ์„ค๋ช… | ์ธ์ฆ ๋ฐฉ์‹ | +|------|------|-----------| +| **Customer** | ๋กœ๊ทธ์ธํ•œ ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž. ์ƒํ’ˆ ์กฐํšŒ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ฐ€๋Šฅ | `@LoginMember` (X-Loopers-LoginId + X-Loopers-LoginPw) | +| **Admin** | ๊ด€๋ฆฌ์ž. ๋ธŒ๋žœ๋“œ/์ƒํ’ˆ/์ฃผ๋ฌธ ๊ด€๋ฆฌ | `@AdminUser` (X-Loopers-Ldap) | + +--- + +## 2. Brand ๋„๋ฉ”์ธ + +| ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| **BrandModel** | Entity | ๋ธŒ๋žœ๋“œ ์—”ํ‹ฐํ‹ฐ. BaseEntity ์ƒ์†. name(BrandName VO) + description | +| **BrandName** | @Embeddable VO | ๋ธŒ๋žœ๋“œ๋ช… ๊ฐ’ ๊ฐ์ฒด. ์œ ๋‹ˆํฌ ์ œ์•ฝ, ๋นˆ๊ฐ’ ๋ถˆ๊ฐ€, `value()` ์ ‘๊ทผ์ž | +| **BrandService** | Domain Service | ๋‹จ์ผ ๋„๋ฉ”์ธ ๋กœ์ง. CRUD, ๋ธŒ๋žœ๋“œ๋ช… ์œ ๋‹ˆํฌ ๊ฒ€์ฆ | +| **BrandFacade** | Application Facade | ์œ ์Šค์ผ€์ด์Šค ์กฐํ•ฉ. ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete | +| **๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์—ฐ์‡„** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ โ†’ ์†Œ์† ์ƒํ’ˆ ์ „์ฒด soft delete. ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ (Q1) | + +--- + +## 3. Product ๋„๋ฉ”์ธ + +| ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| **ProductModel** | Entity | ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ. name, description, price(Money), brandId(ID ์ฐธ์กฐ), likeCount(๋น„์ •๊ทœํ™”) | +| **Money** | @Embeddable VO | ๊ธˆ์•ก ๊ฐ’ ๊ฐ์ฒด. int ๋‚ด๋ถ€ ํƒ€์ž…(์›ํ™”), ์Œ์ˆ˜ ๋ถˆ๊ฐ€, 0์› ํ—ˆ์šฉ. `add()`, `multiply()` ํ–‰์œ„ ๋ฉ”์„œ๋“œ | +| **StockModel** | Entity | ์žฌ๊ณ  ์—”ํ‹ฐํ‹ฐ. Product์™€ 1:1 ๊ด€๊ณ„. `decrease()`, `increase()`, `hasEnough()` ํ–‰์œ„ ๋ฉ”์„œ๋“œ | +| **StockStatus** | ํ‘œ์‹œ ์ƒํƒœ | ๊ณ ๊ฐ์—๊ฒŒ ๋ณด์—ฌ์ฃผ๋Š” ์žฌ๊ณ  ์ƒํƒœ. IN_STOCK(>10), LOW_STOCK(1~10), OUT_OF_STOCK(0) | +| **ProductService** | Domain Service | ์ƒํ’ˆ CRUD, likeCount ์ฆ๊ฐ, soft delete | +| **StockService** | Domain Service | ์žฌ๊ณ  ์ƒ์„ฑ, ์กฐํšŒ, ์ฐจ๊ฐ(`checkAndDecrease`) | +| **ProductFacade** | Application Facade | ์ƒํ’ˆ + Stock ๋™์‹œ ์ƒ์„ฑ, ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ | +| **initialStock** | ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ | ์ƒํ’ˆ ๋“ฑ๋ก ์‹œ ์ดˆ๊ธฐ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ | + +--- + +## 4. Like ๋„๋ฉ”์ธ + +| ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| **LikeModel** | Entity | ์ข‹์•„์š” ์—”ํ‹ฐํ‹ฐ. userId + productId ์œ ๋‹ˆํฌ ์ œ์•ฝ | +| **๋ฉฑ๋“ฑ์„ฑ (Idempotency)** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ์ข‹์•„์š” ์ค‘๋ณต ๋“ฑ๋ก โ†’ ๋ฌด์‹œ + 200 OK. ์ทจ์†Œ ์ค‘๋ณต โ†’ ๋ฌด์‹œ + 200 OK (Q7) | +| **likeCount ๋™๊ธฐํ™”** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ์ข‹์•„์š” ์ถ”๊ฐ€ โ†’ `incrementLikeCount()`, ์ทจ์†Œ โ†’ `decrementLikeCount()`. ์Œ์ˆ˜ ๋ฐฉ์ง€ ๊ฐ€๋“œ ํฌํ•จ (Q28) | +| **LikeService** | Domain Service | ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ, ์กด์žฌ ์—ฌ๋ถ€ ์กฐํšŒ, ๋ชฉ๋ก ์กฐํšŒ | +| **LikeFacade** | Application Facade | ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ฒดํฌ, ํŠธ๋žœ์žญ์…˜ ๋‚ด likeCount ๋™๊ธฐํ™” | + +--- + +## 5. Order ๋„๋ฉ”์ธ + +| ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| **OrderModel** | Entity | ์ฃผ๋ฌธ ์—”ํ‹ฐํ‹ฐ. userId, status(OrderStatus), totalAmount(Money) | +| **OrderItemModel** | Entity | ์ฃผ๋ฌธ ์ƒ์„ธ ์—”ํ‹ฐํ‹ฐ. orderId, productId, ์Šค๋ƒ…์ƒท(productName, productPrice), quantity. `subtotal()` ํ–‰์œ„ ๋ฉ”์„œ๋“œ | +| **OrderStatus** | Enum | ์ฃผ๋ฌธ ์ƒํƒœ. CREATED(ํ˜„์žฌ ์‚ฌ์šฉ) โ†’ CONFIRMED โ†’ SHIPPING โ†’ DELIVERED โ†’ CANCELLED (๋ฏธ๋ž˜ ํ™•์žฅ์šฉ) | +| **์Šค๋ƒ…์ƒท (Snapshot)** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ฐœ๋… | ์ฃผ๋ฌธ ์‹œ์ ์˜ ์ƒํ’ˆ๋ช…, ๊ฐ€๊ฒฉ์„ OrderItem์— ๋ณต์‚ฌ ์ €์žฅ. ์ƒํ’ˆ ์‚ญ์ œ/๋ณ€๊ฒฝ ํ›„์—๋„ ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ ๊ฐ€๋Šฅ (Q9) | +| **All or Nothing** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ์žฌ๊ณ  ๋ถ€์กฑ ๋˜๋Š” ์‚ญ์ œ๋œ ์ƒํ’ˆ ํฌํ•จ ์‹œ ์ฃผ๋ฌธ ์ „์ฒด ์‹คํŒจ. ๋ถ€๋ถ„ ์„ฑ๊ณต ์—†์Œ (Q19) | +| **OrderService** | Domain Service | ์ฃผ๋ฌธ ์ƒ์„ฑ(์ด์•ก ๊ณ„์‚ฐ ํฌํ•จ), ์กฐํšŒ | +| **OrderFacade** | Application Facade | ์ƒํ’ˆ ์กฐํšŒ โ†’ ์žฌ๊ณ  ์ฐจ๊ฐ โ†’ ์ฃผ๋ฌธ ์ƒ์„ฑ. ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ | + +--- + +## 6. ๊ณตํ†ต ํŒจํ„ด + +### 6.1 ์—”ํ‹ฐํ‹ฐ ๊ธฐ๋ฐ˜ + +| ์šฉ์–ด | ์„ค๋ช… | +|------|------| +| **BaseEntity** | ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ์˜ ๋ถ€๋ชจ ํด๋ž˜์Šค. id, createdAt, updatedAt, deletedAt ์ž๋™ ๊ด€๋ฆฌ | +| **Soft Delete** | `deletedAt`์„ ์„ธํŒ…ํ•˜์—ฌ ๋…ผ๋ฆฌ ์‚ญ์ œ. `delete()` / `restore()` ๋ฉ”์„œ๋“œ. ์กฐํšŒ ์‹œ `findByIdAndDeletedAtIsNull` | +| **guard()** | BaseEntity์˜ ๊ฒ€์ฆ ๋ฉ”์„œ๋“œ. `@PrePersist` / `@PreUpdate` ์‹œ ํ˜ธ์ถœ | + +### 6.2 ๊ฐ’ ๊ฐ์ฒด (Value Object) + +| ์šฉ์–ด | ์„ค๋ช… | +|------|------| +| **@Embeddable VO** | JPA ์ž„๋ฒ ๋”ฉ ๊ฐ€๋Šฅํ•œ ๊ฐ’ ๊ฐ์ฒด ํŒจํ„ด. ์ƒ์„ฑ ์‹œ ๊ฒ€์ฆ, `value()` ์ ‘๊ทผ์ž, equals/hashCode | +| **value()** | VO์˜ ๋‚ด๋ถ€ ๊ฐ’ ์ ‘๊ทผ ๋ฉ”์„œ๋“œ. getter ๋Œ€์‹  ์‚ฌ์šฉ (`loginId.value()`, `price.value()`) | + +### 6.3 ์•„ํ‚คํ…์ฒ˜ ๋ ˆ์ด์–ด + +| ์šฉ์–ด | ์„ค๋ช… | +|------|------| +| **Controller** | HTTP ์š”์ฒญ/์‘๋‹ต ์ฒ˜๋ฆฌ, ์ธ์ฆ ์–ด๋…ธํ…Œ์ด์…˜ ์ ์šฉ. `interfaces/api/` ํŒจํ‚ค์ง€ | +| **Facade** | ์œ ์Šค์ผ€์ด์Šค ์กฐํ•ฉ, `@Transactional` ๊ฒฝ๊ณ„ ๊ด€๋ฆฌ, ์—ฌ๋Ÿฌ Service ์กฐํ•ฉ. `application/` ํŒจํ‚ค์ง€ | +| **Service** | ๋‹จ์ผ ๋„๋ฉ”์ธ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง. `domain/` ํŒจํ‚ค์ง€ | +| **Repository** | ๋ฐ์ดํ„ฐ ์ ‘๊ทผ ์ธํ„ฐํŽ˜์ด์Šค(domain) + JPA ๊ตฌํ˜„์ฒด(infrastructure) | + +### 6.4 ์—๋Ÿฌ ์ฒ˜๋ฆฌ + +| ์šฉ์–ด | ์„ค๋ช… | +|------|------| +| **CoreException** | ๋น„์ฆˆ๋‹ˆ์Šค ์˜ˆ์™ธ. ErrorType enum ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒ์„ฑ | +| **ErrorType** | ์—๋Ÿฌ ์œ ํ˜• enum. NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_ERROR (Q32) | +| **ApiResponse** | ๊ณตํ†ต ์‘๋‹ต ๋ž˜ํผ. `meta`(result, errorCode, message) + `data`(์‘๋‹ต ๋ณธ๋ฌธ) | + +### 6.5 ๊ด€๊ณ„ ์„ค๊ณ„ + +| ์šฉ์–ด | ์„ค๋ช… | +|------|------| +| **ID ์ฐธ์กฐ** | JPA ์—ฐ๊ด€๊ด€๊ณ„(@ManyToOne ๋“ฑ) ์—†์ด `Long brandId`, `Long productId`๋กœ๋งŒ ์ฐธ์กฐ (ADR-008) | +| **1:1 ๋ถ„๋ฆฌ** | Product-Stock ๊ด€๊ณ„. ๋ณ€๊ฒฝ ์ด์œ ๊ฐ€ ๋‹ค๋ฅด๋ฏ€๋กœ ๋ณ„๋„ ์—”ํ‹ฐํ‹ฐ/ํ…Œ์ด๋ธ” (ADR-001) | +| **๋น„์ •๊ทœํ™”** | Product.likeCount. ์กฐํšŒ ์„ฑ๋Šฅ์„ ์œ„ํ•ด ์ง‘๊ณ„๊ฐ’์„ ์ €์žฅ. ์“ฐ๊ธฐ ์‹œ ๋™๊ธฐํ™” ํ•„์š” (ADR-002) | diff --git a/docs/design/mermaid/03-sequence-brand-delete.mmd b/docs/design/mermaid/03-sequence-brand-delete.mmd new file mode 100644 index 000000000..4510846c6 --- /dev/null +++ b/docs/design/mermaid/03-sequence-brand-delete.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant A as Admin + participant F as BrandFacade + participant BS as BrandService + participant PS as ProductService + + A->>F: ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์š”์ฒญ (DELETE /api-admin/v1/brands/{brandId}) + Note over F: @AdminUser ์ธ์ฆ + + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>BS: ๋ธŒ๋žœ๋“œ ์กฐํšŒ + alt ๋ธŒ๋žœ๋“œ ์—†์Œ + BS-->>F: NOT_FOUND + end + F->>PS: ์†Œ์† ์ƒํ’ˆ ์ „์ฒด ์กฐํšŒ + loop ๊ฐ ์†Œ์† ์ƒํ’ˆ + F->>PS: ์ƒํ’ˆ soft delete + end + F->>BS: ๋ธŒ๋žœ๋“œ soft delete + end + + F-->>A: 200 OK \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-like-toggle.mmd b/docs/design/mermaid/03-sequence-like-toggle.mmd new file mode 100644 index 000000000..77f372b12 --- /dev/null +++ b/docs/design/mermaid/03-sequence-like-toggle.mmd @@ -0,0 +1,47 @@ +sequenceDiagram + participant C as Customer + participant F as LikeFacade + participant PS as ProductService + participant LS as LikeService + + rect rgb(232, 245, 233) + Note over C,LS: ์ข‹์•„์š” ์ถ”๊ฐ€ + C->>F: ์ข‹์•„์š” ์š”์ฒญ (POST /api/v1/products/{productId}/likes) + Note over F: @LoginMember ์ธ์ฆ + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>PS: ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ + alt ์‚ญ์ œ๋œ ์ƒํ’ˆ + PS-->>F: NOT_FOUND + end + F->>LS: ์ข‹์•„์š” ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ + alt ์ด๋ฏธ ์ข‹์•„์š” ์กด์žฌ + Note over F: ๋ฌด์‹œ (๋ฉฑ๋“ฑ์„ฑ) + else ์ข‹์•„์š” ์—†์Œ + F->>LS: ์ข‹์•„์š” ์ €์žฅ + F->>PS: likeCount ์ฆ๊ฐ€ + end + end + deactivate F + F-->>C: 200 OK (liked: true) + end + + rect rgb(255, 235, 238) + Note over C,LS: ์ข‹์•„์š” ์ทจ์†Œ + C->>F: ์ทจ์†Œ ์š”์ฒญ (DELETE /api/v1/products/{productId}/likes) + Note over F: @LoginMember ์ธ์ฆ + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + F->>LS: ์ข‹์•„์š” ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ + alt ์ข‹์•„์š” ์กด์žฌ + F->>LS: ์ข‹์•„์š” ์‚ญ์ œ + F->>PS: likeCount ๊ฐ์†Œ (์Œ์ˆ˜ ๋ฐฉ์ง€) + else ์ข‹์•„์š” ์—†์Œ + Note over F: ๋ฌด์‹œ (๋ฉฑ๋“ฑ์„ฑ) + end + end + deactivate F + F-->>C: 200 OK (liked: false) + end \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-order-creation.mmd b/docs/design/mermaid/03-sequence-order-creation.mmd new file mode 100644 index 000000000..39836c193 --- /dev/null +++ b/docs/design/mermaid/03-sequence-order-creation.mmd @@ -0,0 +1,31 @@ +sequenceDiagram + participant C as Customer + participant F as OrderFacade + participant PS as ProductService + participant SS as StockService + participant OS as OrderService + + C->>F: ์ฃผ๋ฌธ ์š”์ฒญ (POST /api/v1/orders) + Note over F: @LoginMember ์ธ์ฆ + + activate F + rect rgb(230, 240, 255) + Note right of F: @Transactional + loop ๊ฐ ์ฃผ๋ฌธ ์ƒํ’ˆ + F->>PS: ์ƒํ’ˆ ์กฐํšŒ + alt ์‚ญ์ œ๋œ ์ƒํ’ˆ + PS-->>F: NOT_FOUND + end + F->>SS: ์žฌ๊ณ  ํ™•์ธ ๋ฐ ์ฐจ๊ฐ + alt ์žฌ๊ณ  ๋ถ€์กฑ + SS-->>F: BAD_REQUEST + Note over F: ์ „์ฒด ๋กค๋ฐฑ (All or Nothing) + end + end + F->>OS: ์ฃผ๋ฌธ ์ƒ์„ฑ (์ด์•ก ์„œ๋ฒ„ ๊ณ„์‚ฐ) + Note over OS: Order + OrderItem ์ €์žฅ + Note over OS: ์Šค๋ƒ…์ƒท: ์ƒํ’ˆ๋ช…, ๊ฐ€๊ฒฉ, ์ˆ˜๋Ÿ‰ + end + deactivate F + + F-->>C: 200 OK (์ฃผ๋ฌธ ์ •๋ณด) \ No newline at end of file diff --git a/docs/design/mermaid/03-sequence-product-list.mmd b/docs/design/mermaid/03-sequence-product-list.mmd new file mode 100644 index 000000000..cb907a35d --- /dev/null +++ b/docs/design/mermaid/03-sequence-product-list.mmd @@ -0,0 +1,28 @@ +sequenceDiagram + participant C as Customer + participant A as Admin + participant F as ProductFacade + participant PS as ProductService + participant SS as StockService + + rect rgb(232, 245, 233) + Note over C,SS: Customer ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ + C->>F: GET /api/v1/products?sort=likes_desc&page=0 + F->>PS: ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์‚ญ์ œ ์ œ์™ธ, ์ •๋ ฌ, ํŽ˜์ด์ง€๋„ค์ด์…˜) + F->>SS: ์žฌ๊ณ  ์ •๋ณด ์กฐํ•ฉ + Note over F: ์žฌ๊ณ  ์ƒํƒœ ๋ณ€ํ™˜ + Note over F: >10 โ†’ IN_STOCK + Note over F: 1~10 โ†’ LOW_STOCK + Note over F: 0 โ†’ OUT_OF_STOCK + F-->>C: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ์žฌ๊ณ  ์ƒํƒœ) + end + + rect rgb(227, 242, 253) + Note over A,SS: Admin ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ + A->>F: GET /api-admin/v1/products?page=0 + Note over F: @AdminUser ์ธ์ฆ + F->>PS: ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ ๊ฐ€๋Šฅ) + F->>SS: ์žฌ๊ณ  ์ •๋ณด ์กฐํ•ฉ + Note over F: ์ •ํ™•ํ•œ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ ํ‘œ์‹œ + F-->>A: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ์žฌ๊ณ  ์ˆ˜๋Ÿ‰) + end \ No newline at end of file diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd new file mode 100644 index 000000000..dafa7ac6b --- /dev/null +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -0,0 +1,98 @@ +classDiagram + direction LR + + namespace Brand { + class BrandName { + <> + -String value + } + class BrandModel { + -BrandName name + -String description + } + class BrandFacade { + ์‚ญ์ œ ์‹œ ์ƒํ’ˆ ์—ฐ์‡„ soft delete + } + } + + namespace Product { + class Money { + <> + -int value + +add(Money) Money + +multiply(int) Money + } + class ProductModel { + -String name + -String description + -Money price + -Long brandId + -int likeCount + +incrementLikeCount() + +decrementLikeCount() + } + class StockModel { + -Long productId + -int quantity + +hasEnough(int) boolean + +decrease(int) + +increase(int) + } + class ProductFacade { + ์ƒํ’ˆ + Stock ๋™์‹œ ์ƒ์„ฑ + } + } + + namespace Like { + class LikeModel { + -Long userId + -Long productId + } + class LikeFacade { + ๋ฉฑ๋“ฑ์„ฑ + likeCount ๋™๊ธฐํ™” + } + } + + namespace Order { + class OrderStatus { + <> + CREATED + CONFIRMED + SHIPPING + DELIVERED + CANCELLED + } + class OrderModel { + -Long userId + -OrderStatus status + -Money totalAmount + } + class OrderItemModel { + -Long orderId + -Long productId + -String productName + -Money productPrice + -int quantity + +subtotal() Money + } + class OrderFacade { + ์žฌ๊ณ  ์ฐจ๊ฐ + ์Šค๋ƒ…์ƒท + ์ฃผ๋ฌธ ์ƒ์„ฑ + } + } + + BrandModel *-- BrandName + ProductModel *-- Money + OrderModel *-- OrderStatus + OrderModel *-- Money + OrderItemModel *-- Money + + BrandFacade --> BrandModel + ProductFacade --> ProductModel + ProductFacade --> StockModel + LikeFacade --> LikeModel + OrderFacade --> OrderModel + OrderFacade --> OrderItemModel + + BrandFacade --> ProductModel + LikeFacade --> ProductModel + OrderFacade --> StockModel \ No newline at end of file diff --git a/docs/design/mermaid/05-erd.mmd b/docs/design/mermaid/05-erd.mmd new file mode 100644 index 000000000..8baf2ac4d --- /dev/null +++ b/docs/design/mermaid/05-erd.mmd @@ -0,0 +1,59 @@ +erDiagram + member ||--o{ member_like : "has" + member ||--o{ orders : "places" + brand ||--o{ product : "has" + product ||--|| stock : "has" + product ||--o{ member_like : "receives" + product ||--o{ order_item : "ordered_as" + orders ||--|{ order_item : "contains" + + member { + bigint id PK + varchar login_id UK "LoginId VO" + varchar password "BCrypt encoded" + varchar name "MemberName VO" + varchar email "Email VO, nullable" + date birth_date + timestamp deleted_at "soft delete" + } + brand { + bigint id PK + varchar name UK "BrandName VO" + varchar description + timestamp deleted_at "soft delete" + } + product { + bigint id PK + varchar name + text description + int price "Money VO" + bigint brand_id "refs brand" + int like_count "๋น„์ •๊ทœํ™”" + timestamp deleted_at "soft delete" + } + stock { + bigint id PK + bigint product_id UK "1:1" + int quantity + } + member_like { + bigint id PK + bigint user_id "refs member" + bigint product_id "refs product" + timestamp created_at "์ข‹์•„์š” ์‹œ๊ฐ„, ์ •๋ ฌ ๊ธฐ์ค€(Q23)" + } + orders { + bigint id PK + bigint user_id "refs member" + varchar status "OrderStatus" + int total_amount "Money VO" + timestamp created_at "์ฃผ๋ฌธ์ผ์‹œ, ๋‚ ์งœํ•„ํ„ฐ ๊ธฐ์ค€" + } + order_item { + bigint id PK + bigint order_id "refs orders" + bigint product_id "refs product" + varchar product_name "snapshot" + int product_price "snapshot" + int quantity + } \ No newline at end of file From 3a473e8b632cd582492c740256f7573b79d999c5 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 13:24:41 +0900 Subject: [PATCH 12/19] docs: api-specs checkList --- docs/design/requirements/api-spec.md | 0 docs/design/requirements/checkList.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/design/requirements/api-spec.md create mode 100644 docs/design/requirements/checkList.md diff --git a/docs/design/requirements/api-spec.md b/docs/design/requirements/api-spec.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/design/requirements/checkList.md b/docs/design/requirements/checkList.md new file mode 100644 index 000000000..e69de29bb From 5f0cff435e07efa1cae41a1d523aafebdd869d2c Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 13:32:38 +0900 Subject: [PATCH 13/19] =?UTF-8?q?docs:api-specs=20=EB=B0=8F=20checkList=20?= =?UTF-8?q?md=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/requirements/api-spec.md | 154 ++++++++++++++++++++++++++ docs/design/requirements/checkList.md | 46 ++++++++ 2 files changed, 200 insertions(+) diff --git a/docs/design/requirements/api-spec.md b/docs/design/requirements/api-spec.md index e69de29bb..1f2f5cbb7 100644 --- a/docs/design/requirements/api-spec.md +++ b/docs/design/requirements/api-spec.md @@ -0,0 +1,154 @@ +## ๐ŸŽฏ ๋ฐฐ๊ฒฝ + +**์ข‹์•„์š”** ๋ˆ„๋ฅด๊ณ , **์ฟ ํฐ** ์“ฐ๊ณ , ์ฃผ๋ฌธ ๋ฐ **๊ฒฐ์ œ**ํ•˜๋Š” **๊ฐ์„ฑ ์ด์ปค๋จธ์Šค**. + +๋‚ด๊ฐ€ ์ข‹์•„ํ•˜๋Š” ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ๋“ค์„ ํ•œ ๋ฒˆ์— ๋‹ด์•„ ์ฃผ๋ฌธํ•˜๊ณ , ์œ ์ € ํ–‰๋™์€ ๋žญํ‚น๊ณผ ์ถ”์ฒœ์œผ๋กœ ์—ฐ๊ฒฐ๋ผ์š”. + +์šฐ๋ฆฐ ์ด ํ๋ฆ„์„ ํ•˜๋‚˜์”ฉ ์ง์ ‘ ๋งŒ๋“ค์–ด๊ฐˆ ๊ฑฐ์˜ˆ์š”. + +--- + +## ๐Ÿงญ ์„œ๋น„์Šค ํ๋ฆ„ ์˜ˆ์‹œ + +1. ์‚ฌ์šฉ์ž๊ฐ€ **ํšŒ์›๊ฐ€์ž…**์„ ํ•˜๊ณ  +2. ์—ฌ๋Ÿฌ ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ์„ ๋‘˜๋Ÿฌ๋ณด๊ณ , ๋งˆ์Œ์— ๋“œ๋Š” ์ƒํ’ˆ์—” **์ข‹์•„์š”**๋ฅผ ๋ˆ„๋ฅด์ฃ . +3. ์‚ฌ์šฉ์ž๋Š” **์ฟ ํฐ์„ ๋ฐœ๊ธ‰**๋ฐ›๊ณ , ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ **ํ•œ ๋ฒˆ์— ์ฃผ๋ฌธํ•˜๊ณ  ๊ฒฐ์ œ**ํ•ฉ๋‹ˆ๋‹ค. +4. ์œ ์ €์˜ ํ–‰๋™์€ ๋ชจ๋‘ ๊ธฐ๋ก๋˜๊ณ , ๊ทธ ๋ฐ์ดํ„ฐ๋Š” ์ดํ›„ ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ์œผ๋กœ ํ™•์žฅ๋  ์ˆ˜ ์žˆ์–ด์š”. + +--- + +## โœ… API ์ œ์•ˆ์‚ฌํ•ญ + +- ๋Œ€๊ณ ๊ฐ ๊ธฐ๋Šฅ์€ `/api/v1` prefix ๋ฅผ ํ†ตํ•ด ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + + ```markdown + ์œ ์ € ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ๊ธฐ๋Šฅ์€ ์•„๋ž˜ ํ—ค๋”๋ฅผ ํ†ตํ•ด ์œ ์ €๋ฅผ ์‹๋ณ„ํ•ด ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + ์ธ์ฆ/์ธ๊ฐ€๋Š” ์ฃผ์š” ์Šค์ฝ”ํ”„๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + ์œ ์ €๋Š” ํƒ€ ์œ ์ €์˜ ์ •๋ณด์— ์ง์ ‘ ์ ‘๊ทผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + + * **X-Loopers-LoginId** : ๋กœ๊ทธ์ธ ID + * **X-Loopers-LoginPw** : ๋น„๋ฐ€๋ฒˆํ˜ธ + ``` + +- ์–ด๋“œ๋ฏผ ๊ธฐ๋Šฅ์€ `/api-admin/v1` prefix ๋ฅผ ํ†ตํ•ด ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + + ```markdown + ์–ด๋“œ๋ฏผ ๊ธฐ๋Šฅ์€ ์•„๋ž˜ ํ—ค๋”๋ฅผ ํ†ตํ•ด ์–ด๋“œ๋ฏผ์„ ์‹๋ณ„ํ•ด ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + ์ค‘์•™ ์ง‘์ค‘ํ˜• ์‚ฌ์šฉ์ž ์ธ์ฆ, ์ •๋ณด ๊ฒ€์ƒ‰, ์•ก์„ธ์Šค ์ œ์–ด. + -> ํšŒ์‚ฌ ์‚ฌ๋‚ด ์–ด๋“œ๋ฏผ + ``` + + +## โœ… ์š”๊ตฌ์‚ฌํ•ญ + +## ๐Ÿ‘ค ์œ ์ € (Users) + +| **METHOD** | **URI** | **user_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | ํšŒ์›๊ฐ€์ž… | +| GET | `/api/v1/users/me` | O | ๋‚ด ์ •๋ณด ์กฐํšŒ | +| PUT | `/api/v1/users/password` | O | ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ | + +--- + +## ๐Ÿท ๋ธŒ๋žœ๋“œ & ์ƒํ’ˆ (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ | +| GET | `/api/v1/products` | X | ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | +| GET | `/api/v1/products/{productId}` | X | ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ | + +### โœ… ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ + +| **ํŒŒ๋ผ๋ฏธํ„ฐ** | **์˜ˆ์‹œ** | **์„ค๋ช…** | +| --- | --- | --- | +| `brandId` | `1` | ํŠน์ • ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ๋งŒ ํ•„ํ„ฐ๋ง | +| `sort` | `latest` / `price_asc` / `likes_desc` | ์ •๋ ฌ ๊ธฐ์ค€ | +| `page` | `0` | ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (๊ธฐ๋ณธ๊ฐ’ 0) | +| `size` | `20` | ํŽ˜์ด์ง€๋‹น ์ƒํ’ˆ ์ˆ˜ (๊ธฐ๋ณธ๊ฐ’ 20) | + +> ๐Ÿ’ก ์ •๋ ฌ ๊ธฐ์ค€์€ ์„ ํƒ ๊ตฌํ˜„์ž…๋‹ˆ๋‹ค. +> +> +> ํ•„์ˆ˜๋Š” `latest`, ๊ทธ ์™ธ๋Š” `price_asc`, `likes_desc` ์ •๋„๋กœ ์ œํ•œํ•ด๋„ ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. +> + +--- + +## ๐Ÿท ๋ธŒ๋žœ๋“œ & ์ƒํ’ˆ ADMIN + +| **METHOD** | **URI** | **ldap_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **๋“ฑ๋ก๋œ ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ** | +| POST | `/api-admin/v1/brands` | O | **๋ธŒ๋žœ๋“œ ๋“ฑ๋ก** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **๋ธŒ๋žœ๋“œ ์ •๋ณด ์ˆ˜์ •** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **๋ธŒ๋žœ๋“œ ์‚ญ์ œ** +* ๋ธŒ๋žœ๋“œ ์ œ๊ฑฐ ์‹œ, ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ๋“ค๋„ ์‚ญ์ œ๋˜์–ด์•ผ ํ•จ | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **๋“ฑ๋ก๋œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ** | +| GET | `/api-admin/v1/products/{productId}` | O | **์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ** | +| POST | `/api-admin/v1/products` | O | **์ƒํ’ˆ ๋“ฑ๋ก** +* ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋Š” ์ด๋ฏธ ๋“ฑ๋ก๋œ ๋ธŒ๋žœ๋“œ์—ฌ์•ผ ํ•จ | +| PUT | `/api-admin/v1/products/{productId}` | O | **์ƒํ’ˆ ์ •๋ณด ์ˆ˜์ •** +* ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋Š” ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Œ | +| DELETE | `/api-admin/v1/products/{productId}` | O | **์ƒํ’ˆ ์‚ญ์ œ** | + +> ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ ์ •๋ณด ์ค‘ ๊ณ ๊ฐ๊ณผ ์–ด๋“œ๋ฏผ์—๊ฒŒ ์ œ๊ณต๋˜์–ด์•ผ ํ•  ์ •๋ณด์— ๋Œ€ํ•ด ๊ณ ๋ฏผํ•ด๋ณด์„ธ์š”. +> + +--- + +## โค๏ธ ์ข‹์•„์š” (Likes) + +| **METHOD** | **URI** | **user_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก | +| DELETE | `/api/v1/products/{productId}/likes` | O | ์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ | +| GET | `/api/v1/users/{userId}/likes` | O | ๋‚ด๊ฐ€ ์ข‹์•„์š” ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ | + +--- + +## ๐Ÿงพ ์ฃผ๋ฌธ (Orders) + +| **METHOD** | **URI** | **user_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | ์ฃผ๋ฌธ ์š”์ฒญ | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | ์œ ์ €์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ | +| GET | `/api/v1/orders/{orderId}` | O | ๋‹จ์ผ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ | + +**์š”์ฒญ ์˜ˆ์‹œ:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **๊ฒฐ์ œ**๋Š” ๊ณผ์ • ์ง„ํ–‰ ์ค‘, **์ถ”๊ฐ€๋กœ ๊ฐœ๋ฐœ**ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค! +**์ฃผ๋ฌธ ์ •๋ณด**์—๋Š” ๋‹น์‹œ์˜ ์ƒํ’ˆ ์ •๋ณด๊ฐ€ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +**์ฃผ๋ฌธ ์‹œ์— ๋‹ค์Œ ๋™์ž‘์ด ๋ณด์žฅ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค :** ์ƒํ’ˆ ์žฌ๊ณ  ํ™•์ธ ๋ฐ ์ฐจ๊ฐ +> + +--- + +## ๐Ÿงพ ์ฃผ๋ฌธ ADMIN + +| **METHOD** | **URI** | **ldap_required** | **์„ค๋ช…** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ | +| GET | `/api-admin/v1/orders/{orderId}` | O | ๋‹จ์ผ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ | + +--- + +### ๐Ÿ“ก ๋‚˜์•„๊ฐ€๋ฉฐ + +> โš™๏ธ **๋ชจ๋“  ๊ธฐ๋Šฅ์˜ ๋™์ž‘์„ ๊ฐœ๋ฐœํ•œ ํ›„์— ๋™์‹œ์„ฑ, ๋ฉฑ๋“ฑ์„ฑ, ์ผ๊ด€์„ฑ, ๋А๋ฆฐ ์กฐํšŒ, ๋™์‹œ ์ฃผ๋ฌธ ๋“ฑ ์‹ค์ œ ์„œ๋น„์Šค์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋“ค์„ ํ•ด๊ฒฐํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.** +> \ No newline at end of file diff --git a/docs/design/requirements/checkList.md b/docs/design/requirements/checkList.md index e69de29bb..47fe20a75 100644 --- a/docs/design/requirements/checkList.md +++ b/docs/design/requirements/checkList.md @@ -0,0 +1,46 @@ +### ๐Ÿท Product / Brand ๋„๋ฉ”์ธ + +- [ ] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. +- [ ] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค +- [ ] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค +- [ ] ์žฌ๊ณ ์˜ ์Œ์ˆ˜ ๋ฐฉ์ง€ ์ฒ˜๋ฆฌ๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค + +### ๐Ÿ‘ Like ๋„๋ฉ”์ธ + +- [ ] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค +- [ ] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค +- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿ›’ Order ๋„๋ฉ”์ธ + +- [ ] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค +- [ ] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค +- [ ] ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค +- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค + +- [ ] ๋„๋ฉ”์ธ ๋‚ด๋ถ€ ๊ทœ์น™์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค +- [ ] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ Application Layer ์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค +- [ ] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค +- [ ] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋™์ผํ•œ ๋„๋ฉ”์ธ ๊ฒฝ๊ณ„ ๋‚ด์˜ ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค + +### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** + +- [ ] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค + - Application โ†’ **Domain** โ† Infrastructure +- [ ] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค +- [ ] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค +- [ ] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค +- [ ] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) +- [ ] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค + +### ๐ŸŽฏ Feature Suggestions + +- ์ƒํ’ˆ์ด ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•ด์•ผ ํ• ๊นŒ? +- ์ƒํ’ˆ ์ƒ์„ธ์—์„œ ๋ธŒ๋žœ๋“œ๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋ ค๋ฉด ๋ˆ„๊ฐ€ ์กฐํ•ฉํ•ด์•ผ ํ• ๊นŒ? +- VO๋ฅผ ๋„์ž…ํ•œ ์ด์œ ๋Š” ๋ฌด์—‡์ด๋ฉฐ, ์–ด๋А ์‹œ์ ์—์„œ ์œ ๋ฆฌํ•˜๊ฒŒ ์ž‘์šฉํ–ˆ๋Š”๊ฐ€? +- Order, Product, User ์ค‘ ๋ˆ„๊ฐ€ ์–ด๋–ค ์ฑ…์ž„์„ ๊ฐ–๋Š” ๊ฒƒ์ด ์ž์—ฐ์Šค๋Ÿฌ์› ๋‚˜? +- Repository Interface ๋ฅผ Domain Layer์— ๋‘๋Š” ์ด์œ ๋Š”? +- ์ฒ˜์Œ์—” ๋„๋ฉ”์ธ์— ๋‘๋ ค ํ–ˆ์ง€๋งŒ, ๊ฒฐ๊ตญ Application Layer๋กœ ์˜ฎ๊ธด ์ด์œ ๋Š”? +- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ๊ฐ€์žฅ ๋จผ์ € ๊ณ ๋ คํ•œ ๊ฑด ๋ฌด์—‡์ด์—ˆ๋‚˜? \ No newline at end of file From 37f468de8a28320183ef2f5d32fda4f7c406c0aa Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 16:53:08 +0900 Subject: [PATCH 14/19] =?UTF-8?q?fix:Brand/Product/Stock=EC=9D=98=20?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EB=94=94=EB=93=9C=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=EB=A1=9C=20=EB=B0=B0=EC=B9=98=EC=8B=9C?= =?UTF-8?q?=EC=BC=9C=20=EB=AC=B8=EC=84=9C=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/mermaid/02-ubiquitous-language.md | 32 +++++++++++++------ docs/design/mermaid/04-class-diagram.mmd | 5 +-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md index c7138535e..b8ef8bd86 100644 --- a/docs/design/mermaid/02-ubiquitous-language.md +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -14,19 +14,20 @@ --- -## 2. Brand ๋„๋ฉ”์ธ +## 2. ์นดํƒˆ๋กœ๊ทธ BC (Brand + Product + Stock) + +> ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์‹ฌ์‚ฌ: "ํŒ๋งคํ•  ์ƒํ’ˆ ์นดํƒˆ๋กœ๊ทธ๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค" +> ๋ธŒ๋žœ๋“œ ์‚ญ์ œ โ†’ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete๊ฐ€ ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ๊ฐ™์€ BC์— ์†ํ•œ๋‹ค. + +### Brand (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ) | ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | |------|------|------| | **BrandModel** | Entity | ๋ธŒ๋žœ๋“œ ์—”ํ‹ฐํ‹ฐ. BaseEntity ์ƒ์†. name(BrandName VO) + description | | **BrandName** | @Embeddable VO | ๋ธŒ๋žœ๋“œ๋ช… ๊ฐ’ ๊ฐ์ฒด. ์œ ๋‹ˆํฌ ์ œ์•ฝ, ๋นˆ๊ฐ’ ๋ถˆ๊ฐ€, `value()` ์ ‘๊ทผ์ž | | **BrandService** | Domain Service | ๋‹จ์ผ ๋„๋ฉ”์ธ ๋กœ์ง. CRUD, ๋ธŒ๋žœ๋“œ๋ช… ์œ ๋‹ˆํฌ ๊ฒ€์ฆ | -| **BrandFacade** | Application Facade | ์œ ์Šค์ผ€์ด์Šค ์กฐํ•ฉ. ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete | -| **๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์—ฐ์‡„** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ โ†’ ์†Œ์† ์ƒํ’ˆ ์ „์ฒด soft delete. ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ (Q1) | ---- - -## 3. Product ๋„๋ฉ”์ธ +### Product (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ) | ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | |------|------|------| @@ -36,12 +37,21 @@ | **StockStatus** | ํ‘œ์‹œ ์ƒํƒœ | ๊ณ ๊ฐ์—๊ฒŒ ๋ณด์—ฌ์ฃผ๋Š” ์žฌ๊ณ  ์ƒํƒœ. IN_STOCK(>10), LOW_STOCK(1~10), OUT_OF_STOCK(0) | | **ProductService** | Domain Service | ์ƒํ’ˆ CRUD, likeCount ์ฆ๊ฐ, soft delete | | **StockService** | Domain Service | ์žฌ๊ณ  ์ƒ์„ฑ, ์กฐํšŒ, ์ฐจ๊ฐ(`checkAndDecrease`) | -| **ProductFacade** | Application Facade | ์ƒํ’ˆ + Stock ๋™์‹œ ์ƒ์„ฑ, ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ | | **initialStock** | ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ | ์ƒํ’ˆ ๋“ฑ๋ก ์‹œ ์ดˆ๊ธฐ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰ | +### Application Layer + +| ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | +|------|------|------| +| **BrandFacade** | Application Facade | ์œ ์Šค์ผ€์ด์Šค ์กฐํ•ฉ. ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete | +| **ProductFacade** | Application Facade | ์ƒํ’ˆ + Stock ๋™์‹œ ์ƒ์„ฑ, ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ | +| **๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์—ฐ์‡„** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ | ๋ธŒ๋žœ๋“œ ์‚ญ์ œ โ†’ ์†Œ์† ์ƒํ’ˆ ์ „์ฒด soft delete. ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ (Q1) | + --- -## 4. Like ๋„๋ฉ”์ธ +## 3. ์ข‹์•„์š” BC (Like) + +> ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์‹ฌ์‚ฌ: "๊ณ ๊ฐ์˜ ์ƒํ’ˆ ์„ ํ˜ธ๋ฅผ ์ถ”์ ํ•œ๋‹ค" | ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | |------|------|------| @@ -53,7 +63,9 @@ --- -## 5. Order ๋„๋ฉ”์ธ +## 4. ์ฃผ๋ฌธ BC (Order) + +> ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์‹ฌ์‚ฌ: "์ฃผ๋ฌธ ์ด๋ ฅ์„ ๊ธฐ๋กํ•˜๊ณ  ๊ด€๋ฆฌํ•œ๋‹ค" | ์šฉ์–ด | ํƒ€์ž… | ์„ค๋ช… | |------|------|------| @@ -67,7 +79,7 @@ --- -## 6. ๊ณตํ†ต ํŒจํ„ด +## 5. ๊ณตํ†ต ํŒจํ„ด ### 6.1 ์—”ํ‹ฐํ‹ฐ ๊ธฐ๋ฐ˜ diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd index dafa7ac6b..dbf61bc92 100644 --- a/docs/design/mermaid/04-class-diagram.mmd +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -1,7 +1,7 @@ classDiagram direction LR - namespace Brand { + namespace Catalog { class BrandName { <> -String value @@ -13,9 +13,6 @@ classDiagram class BrandFacade { ์‚ญ์ œ ์‹œ ์ƒํ’ˆ ์—ฐ์‡„ soft delete } - } - - namespace Product { class Money { <> -int value From 7513c0d8d487922fcc6bf20bcfd067428112482a Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 17:51:50 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandName VO: ๋นˆ๊ฐ’/null/๊ณต๋ฐฑ ๊ฒ€์ฆ, equals/hashCode - BrandModel Entity: BaseEntity ์ƒ์†, soft delete - BrandService: ๋“ฑ๋ก(์ค‘๋ณต์ฒดํฌ), ์กฐํšŒ, ์ˆ˜์ •(์ค‘๋ณต์ฒดํฌ), ์‚ญ์ œ, ๋ชฉ๋ก - BrandRepository ์ธํ„ฐํŽ˜์ด์Šค + JPA ๊ตฌํ˜„์ฒด - Customer API: GET /api/v1/brands/{brandId} - Admin API: POST/GET/PUT/DELETE /api-admin/v1/brands - ํ…Œ์ŠคํŠธ: BrandNameTest, BrandModelTest, BrandServiceTest, BrandServiceIntegrationTest, BrandV1ApiE2ETest - HTTP ํ…Œ์ŠคํŠธ ํŒŒ์ผ: brand-v1.http Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/brand/BrandModel.java | 38 +++ .../com/loopers/domain/brand/BrandName.java | 41 +++ .../loopers/domain/brand/BrandRepository.java | 17 + .../loopers/domain/brand/BrandService.java | 73 ++++ .../brand/BrandJpaRepository.java | 11 + .../brand/BrandRepositoryImpl.java | 37 +++ .../api/brand/BrandV1Controller.java | 24 ++ .../interfaces/api/brand/BrandV1Dto.java | 16 + .../brand/admin/BrandAdminV1Controller.java | 63 ++++ .../api/brand/admin/BrandAdminV1Dto.java | 38 +++ .../loopers/domain/brand/BrandModelTest.java | 72 ++++ .../loopers/domain/brand/BrandNameTest.java | 74 +++++ .../brand/BrandServiceIntegrationTest.java | 208 ++++++++++++ .../domain/brand/BrandServiceTest.java | 294 ++++++++++++++++ .../api/brand/BrandV1ApiE2ETest.java | 314 ++++++++++++++++++ http/commerce-api/brand-v1.http | 29 ++ 16 files changed, 1349 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java create mode 100644 http/commerce-api/brand-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..c3183b6f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,38 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Embedded + private BrandName name; + + @Column(name = "description") + private String description; + + protected BrandModel() {} + + public BrandModel(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public void update(BrandName name, String description) { + this.name = name; + this.description = description; + } + + public BrandName name() { + return name; + } + + public String description() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java new file mode 100644 index 000000000..1e26b649a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandName.java @@ -0,0 +1,41 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandName { + + @Column(name = "name", nullable = false, unique = true) + private String value; + + public BrandName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BrandName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..26a0febef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface BrandRepository { + + BrandModel save(BrandModel brand); + + Optional findById(Long id); + + Optional findByName(String name); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..0691e2de2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,73 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description) { + BrandName brandName = new BrandName(name); + + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค."); + }); + + BrandModel brand = new BrandModel(brandName, description); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandModel getBrand(Long brandId) { + BrandModel brand = findById(brandId); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return brand; + } + + @Transactional(readOnly = true) + public BrandModel getBrandForAdmin(Long brandId) { + return findById(brandId); + } + + @Transactional + public BrandModel update(Long brandId, String name, String description) { + BrandModel brand = findById(brandId); + BrandName newName = new BrandName(name); + + if (!brand.name().equals(newName)) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค."); + }); + } + + brand.update(newName, description); + return brand; + } + + @Transactional + public void delete(Long brandId) { + BrandModel brand = findById(brandId); + brand.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } + + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..87c8e5dc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByNameValue(String value); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..6f7a3684d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameValue(name); + } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..380c2b8e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandService brandService; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandModel brand = brandService.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..7f969dcf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.BrandModel; + +public class BrandV1Dto { + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(BrandModel model) { + return new BrandResponse(model.getId(), model.name().value(), model.description()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java new file mode 100644 index 000000000..bf201ca3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller { + + private final BrandService brandService; + + @PostMapping + public ApiResponse create( + @RequestBody BrandAdminV1Dto.CreateRequest request + ) { + BrandModel brand = brandService.register(request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page result = brandService.getAll(PageRequest.of(page, size)); + return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @PathVariable Long brandId, + @RequestBody BrandAdminV1Dto.UpdateRequest request + ) { + BrandModel brand = brandService.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@PathVariable Long brandId) { + brandService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java new file mode 100644 index 000000000..dc88693d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.brand.admin; + +import com.loopers.domain.brand.BrandModel; + +import java.time.ZonedDateTime; + +public class BrandAdminV1Dto { + + public record CreateRequest( + String name, + String description + ) {} + + public record UpdateRequest( + String name, + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static BrandResponse from(BrandModel model) { + return new BrandResponse( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..9c13a938f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class BrandModelTest { + + @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + BrandName name = new BrandName("๋‚˜์ดํ‚ค"); + String description = "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"; + + // when + BrandModel brand = new BrandModel(name, description); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isEqualTo(description) + ); + } + + @DisplayName("description์ด null์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithNullDescription() { + // given + BrandName name = new BrandName("์•„๋””๋‹ค์Šค"); + + // when + BrandModel brand = new BrandModel(name, null); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isNull() + ); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("์ด๋ฆ„๊ณผ ์„ค๋ช…์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void updatesNameAndDescription() { + // given + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + BrandName newName = new BrandName("๋‰ด๋ฐœ๋ž€์Šค"); + String newDescription = "๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ"; + + // when + brand.update(newName, newDescription); + + // then + assertAll( + () -> assertThat(brand.name()).isEqualTo(newName), + () -> assertThat(brand.description()).isEqualTo(newDescription) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java new file mode 100644 index 000000000..33c9fcf4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandNameTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandNameTest { + + @DisplayName("BrandName ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("null, ๋นˆ ๋ฌธ์ž์—ด, ๊ณต๋ฐฑ์€ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void rejectsBlankValues(String value) { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new BrandName(value)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void validNameCreatesSuccessfully() { + // given + String value = "๋‚˜์ดํ‚ค"; + + // when + BrandName name = new BrandName(value); + + // then + assertThat(name.value()).isEqualTo(value); + } + } + + @DisplayName("๋™๋“ฑ์„ฑ ๋น„๊ต") + @Nested + class Equals { + + @DisplayName("๊ฐ™์€ ๊ฐ’์ด๋ฉด ๋™์ผํ•˜๋‹ค") + @Test + void sameValueMeansEqual() { + // given + BrandName one = new BrandName("๋‚˜์ดํ‚ค"); + BrandName another = new BrandName("๋‚˜์ดํ‚ค"); + + // when & then + assertThat(one).isEqualTo(another); + assertThat(one.hashCode()).isEqualTo(another.hashCode()); + } + + @DisplayName("๋‹ค๋ฅธ ๊ฐ’์ด๋ฉด ๋‹ค๋ฅด๋‹ค") + @Test + void differentValueMeansNotEqual() { + // given + BrandName one = new BrandName("๋‚˜์ดํ‚ค"); + BrandName another = new BrandName("์•„๋””๋‹ค์Šค"); + + // when & then + assertThat(one).isNotEqualTo(another); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..40d981593 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋“ฑ๋ก") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๋“ฑ๋กํ•˜๋ฉด ๋ธŒ๋žœ๋“œ๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค") + @Test + void createsBrandSuccessfully() { + // given & when + BrandModel result = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.description()).isEqualTo("์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ") + ); + } + + @DisplayName("์ค‘๋ณต ๋ธŒ๋žœ๋“œ๋ช…์ด๋ฉด CONFLICT ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnDuplicateName() { + // given + brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register("๋‚˜์ดํ‚ค", "๋‹ค๋ฅธ ์„ค๋ช…")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์กฐํšŒ") + @Nested + class GetBrand { + + @DisplayName("์กด์žฌํ•˜๊ณ  ๋ฏธ์‚ญ์ œ ์ƒํƒœ๋ฉด ๋ธŒ๋žœ๋“œ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsBrand() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + BrandModel result = brandService.getBrand(saved.getId()); + + // then + assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenDeleted() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + brandService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("์ด๋ฆ„๊ณผ ์„ค๋ช…์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void updatesSuccessfully() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + BrandModel result = brandService.update(saved.getId(), "๋‰ด๋ฐœ๋ž€์Šค", "๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("๋‰ด๋ฐœ๋ž€์Šค"), + () -> assertThat(result.description()).isEqualTo("๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ") + ); + } + + @DisplayName("๋™์ผ๋ช… ์œ ์ง€ ์‹œ ์ค‘๋ณต ์ฒดํฌ๋ฅผ ํ†ต๊ณผํ•œ๋‹ค") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + BrandModel result = brandService.update(saved.getId(), "๋‚˜์ดํ‚ค", "์„ค๋ช…๋งŒ ๋ณ€๊ฒฝ"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.description()).isEqualTo("์„ค๋ช…๋งŒ ๋ณ€๊ฒฝ") + ); + } + + @DisplayName("๋‹ค๋ฅธ ์ด๋ฆ„์œผ๋กœ ๋ณ€๊ฒฝ ์‹œ ์ค‘๋ณต์ด๋ฉด CONFLICT ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsOnDuplicateNameChange() { + // given + brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + BrandModel target = brandService.register("์•„๋””๋‹ค์Šค", "๋‹ค๋ฅธ ๋ธŒ๋žœ๋“œ"); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(target.getId(), "๋‚˜์ดํ‚ค", "๋ณ€๊ฒฝ ์‹œ๋„")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์‚ญ์ œ") + @Nested + class Delete { + + @DisplayName("soft delete ํ›„ customer ์กฐํšŒ์—์„œ ์ œ์™ธ๋œ๋‹ค") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + brandService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete ํ›„ admin ์กฐํšŒ์—์„œ๋Š” ํฌํ•จ๋œ๋‹ค") + @Test + void includedInAdminQueryAfterDelete() { + // given + BrandModel saved = brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + brandService.delete(saved.getId()); + + // then + BrandModel result = brandService.getBrandForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ") + @Nested + class GetAll { + + @DisplayName("ํŽ˜์ด์ง•๋œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsPagedResult() { + // given + brandService.register("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ "); + brandService.register("์•„๋””๋‹ค์Šค", "์Šคํฌ์ธ "); + brandService.register("๋‰ด๋ฐœ๋ž€์Šค", "๋ผ์ดํ”„์Šคํƒ€์ผ"); + + // when + Page result = brandService.getAll(PageRequest.of(0, 2)); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(3), + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getTotalPages()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..6577b447d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,294 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋“ฑ๋ก") + @Nested + class Register { + + @DisplayName("์„ฑ๊ณตํ•˜๋ฉด ์ €์žฅ๋œ BrandModel์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsSavedBrand() { + // given + String name = "๋‚˜์ดํ‚ค"; + String description = "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"; + when(brandRepository.findByName(name)).thenReturn(Optional.empty()); + when(brandRepository.save(any(BrandModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + BrandModel result = brandService.register(name, description); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description) + ); + verify(brandRepository).save(any(BrandModel.class)); + } + + @DisplayName("์ค‘๋ณต ๋ธŒ๋žœ๋“œ๋ช…์ด๋ฉด CONFLICT ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnDuplicateName() { + // given + String name = "๋‚˜์ดํ‚ค"; + BrandModel existing = new BrandModel(new BrandName(name), "๊ธฐ์กด ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findByName(name)).thenReturn(Optional.of(existing)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.register(name, "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + verify(brandRepository, never()).save(any()); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์กฐํšŒ") + @Nested + class GetBrand { + + @DisplayName("์กด์žฌํ•˜๊ณ  ๋ฏธ์‚ญ์ œ ์ƒํƒœ๋ฉด BrandModel์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsBrandWhenExistsAndNotDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrand(brandId); + + // then + assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrand(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ (Admin)") + @Nested + class GetBrandForAdmin { + + @DisplayName("์กด์žฌํ•˜๋ฉด ์‚ญ์ œ ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsBrandRegardlessOfDeletion() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getBrandForAdmin(brandId); + + // then + assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.getBrandForAdmin(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("์„ฑ๊ณตํ•˜๋ฉด ๋ณ€๊ฒฝ๋œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void updatesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("๋‰ด๋ฐœ๋ž€์Šค")).thenReturn(Optional.empty()); + + // when + BrandModel result = brandService.update(brandId, "๋‰ด๋ฐœ๋ž€์Šค", "๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("๋‰ด๋ฐœ๋ž€์Šค"), + () -> assertThat(result.description()).isEqualTo("๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ") + ); + } + + @DisplayName("๋™์ผ๋ช… ์œ ์ง€ ์‹œ ์ค‘๋ณต ์ฒดํฌ๋ฅผ ํ†ต๊ณผํ•œ๋‹ค") + @Test + void skipsDuplicateCheckWhenSameName() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.update(brandId, "๋‚˜์ดํ‚ค", "์„ค๋ช… ๋ณ€๊ฒฝ"); + + // then + assertAll( + () -> assertThat(result.name().value()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.description()).isEqualTo("์„ค๋ช… ๋ณ€๊ฒฝ") + ); + verify(brandRepository, never()).findByName(any()); + } + + @DisplayName("๋‹ค๋ฅธ ์ด๋ฆ„์œผ๋กœ ๋ณ€๊ฒฝ ์‹œ ์ค‘๋ณต์ด๋ฉด CONFLICT ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnDuplicateNameChange() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + BrandModel other = new BrandModel(new BrandName("์•„๋””๋‹ค์Šค"), "๋‹ค๋ฅธ ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(brandRepository.findByName("์•„๋””๋‹ค์Šค")).thenReturn(Optional.of(other)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.update(brandId, "์•„๋””๋‹ค์Šค", "๋ณ€๊ฒฝ ์‹œ๋„")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์‚ญ์ œ") + @Nested + class Delete { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ฅผ soft delete ํ•œ๋‹ค") + @Test + void softDeletesSuccessfully() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + brandService.delete(brandId); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> brandService.delete(brandId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ") + @Nested + class GetAll { + + @DisplayName("ํŽ˜์ด์ง•๋œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsPagedResult() { + // given + Pageable pageable = PageRequest.of(0, 10); + List brands = List.of( + new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ "), + new BrandModel(new BrandName("์•„๋””๋‹ค์Šค"), "์Šคํฌ์ธ ") + ); + Page page = new PageImpl<>(brands, pageable, brands.size()); + when(brandRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = brandService.getAll(pageable); + + // then + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(2), + () -> assertThat(result.getContent()).hasSize(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..b71af55aa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,314 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name, String description) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class CustomerGetBrand { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด 200๊ณผ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(response.getBody().data().description()).isEqualTo("์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ") + ); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + deleteBrand(brandId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class AdminCreate { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๋“ฑ๋กํ•˜๋ฉด 200๊ณผ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithBrandInfo() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(response.getBody().data().description()).isEqualTo("์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("์ค‘๋ณต ๋ธŒ๋žœ๋“œ๋ช…์ด๋ฉด 409๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns409OnDuplicateName() { + // given + createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("๋‚˜์ดํ‚ค", "๋‹ค๋ฅธ ์„ค๋ช…"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("๋นˆ ์ด๋ฆ„์ด๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns400OnEmptyName() { + // given + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest("", "์„ค๋ช…"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class AdminGetAll { + + @DisplayName("๋ธŒ๋žœ๋“œ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithPagedList() { + // given + createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ "); + createBrand("์•„๋””๋‹ค์Šค", "์Šคํฌ์ธ "); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class AdminGetBrand { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด 200๊ณผ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithBrandInfo() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("๋‚˜์ดํ‚ค") + ); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฅผ ์กฐํšŒํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class AdminUpdate { + + @DisplayName("์ˆ˜์ • ์„ฑ๊ณต ์‹œ 200๊ณผ ์ˆ˜์ •๋œ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("๋‰ด๋ฐœ๋ž€์Šค", "๋ผ์ดํ”„์Šคํƒ€์ผ"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("๋‰ด๋ฐœ๋ž€์Šค"), + () -> assertThat(response.getBody().data().description()).isEqualTo("๋ผ์ดํ”„์Šคํƒ€์ผ") + ); + } + + @DisplayName("์ค‘๋ณต๋ช…์œผ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด 409๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns409OnDuplicateName() { + // given + createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ "); + Long targetId = createBrand("์•„๋””๋‹ค์Šค", "์Šคํฌ์ธ "); + BrandAdminV1Dto.UpdateRequest request = new BrandAdminV1Dto.UpdateRequest("๋‚˜์ดํ‚ค", "๋ณ€๊ฒฝ ์‹œ๋„"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class AdminDelete { + + @DisplayName("์‚ญ์ œ ์„ฑ๊ณต ์‹œ 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..3a3a35c55 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,29 @@ +### [Customer] ๋ธŒ๋žœ๋“œ ์กฐํšŒ +GET {{commerce-api}}/api/v1/brands/1 + +### [Admin] ๋ธŒ๋žœ๋“œ ๋“ฑ๋ก +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "๋‚˜์ดํ‚ค", + "description": "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ" +} + +### [Admin] ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=10 + +### [Admin] ๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/brands/1 + +### [Admin] ๋ธŒ๋žœ๋“œ ์ˆ˜์ • +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json + +{ + "name": "๋‰ด๋ฐœ๋ž€์Šค", + "description": "๋ผ์ดํ”„์Šคํƒ€์ผ ๋ธŒ๋žœ๋“œ" +} + +### [Admin] ๋ธŒ๋žœ๋“œ ์‚ญ์ œ +DELETE {{commerce-api}}/api-admin/v1/brands/1 From 0cef9e0c39b2b6fd86a0775e45412f73337fa958 Mon Sep 17 00:00:00 2001 From: praesentia Date: Thu, 26 Feb 2026 18:40:50 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20Product=20+=20Stock=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Money VO, ProductModel, StockModel ์—”ํ‹ฐํ‹ฐ ๊ตฌํ˜„ - ProductService, StockService ๋„๋ฉ”์ธ ์„œ๋น„์Šค ๊ตฌํ˜„ - Customer/Admin API ์ปจํŠธ๋กค๋Ÿฌ ๋ฐ DTO ๊ตฌํ˜„ - ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete ๊ตฌํ˜„ - Unit/Integration/E2E ํ…Œ์ŠคํŠธ ์ž‘์„ฑ (51๊ฐœ Unit ํ…Œ์ŠคํŠธ ํ†ต๊ณผ) - HTTP ์ˆ˜๋™ ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ถ”๊ฐ€ Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/brand/BrandService.java | 8 + .../com/loopers/domain/product/Money.java | 49 +++ .../loopers/domain/product/ProductModel.java | 63 ++++ .../domain/product/ProductRepository.java | 24 ++ .../domain/product/ProductService.java | 102 +++++ .../domain/product/ProductSortType.java | 20 + .../com/loopers/domain/stock/StockModel.java | 53 +++ .../loopers/domain/stock/StockRepository.java | 10 + .../loopers/domain/stock/StockService.java | 26 ++ .../com/loopers/domain/stock/StockStatus.java | 13 + .../product/ProductJpaRepository.java | 19 + .../product/ProductRepositoryImpl.java | 53 +++ .../stock/StockJpaRepository.java | 11 + .../stock/StockRepositoryImpl.java | 25 ++ .../api/product/ProductV1Controller.java | 48 +++ .../interfaces/api/product/ProductV1Dto.java | 31 ++ .../admin/ProductAdminV1Controller.java | 82 +++++ .../api/product/admin/ProductAdminV1Dto.java | 52 +++ .../domain/brand/BrandServiceTest.java | 31 +- .../com/loopers/domain/product/MoneyTest.java | 111 ++++++ .../domain/product/ProductModelTest.java | 91 +++++ .../ProductServiceIntegrationTest.java | 240 ++++++++++++ .../domain/product/ProductServiceTest.java | 340 +++++++++++++++++ .../loopers/domain/stock/StockModelTest.java | 141 +++++++ .../domain/stock/StockServiceTest.java | 97 +++++ .../api/product/ProductV1ApiE2ETest.java | 347 ++++++++++++++++++ http/commerce-api/product-v1.http | 45 +++ 27 files changed, 2131 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java create mode 100644 http/commerce-api/product-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 0691e2de2..cbcb9d896 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,5 +1,7 @@ package com.loopers.domain.brand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -8,11 +10,14 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @RequiredArgsConstructor @Component public class BrandService { private final BrandRepository brandRepository; + private final ProductRepository productRepository; @Transactional public BrandModel register(String name, String description) { @@ -59,6 +64,9 @@ public BrandModel update(Long brandId, String name, String description) { public void delete(Long brandId) { BrandModel brand = findById(brandId); brand.delete(); + + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..d26bd62a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,49 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + @Column(name = "price", nullable = false) + private int value; + + public Money(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.value = value; + } + + public int value() { + return value; + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money multiply(int multiplier) { + return new Money(this.value * multiplier); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money that)) return false; + return value == that.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..4e455250b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,63 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Embedded + private Money price; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected ProductModel() {} + + public ProductModel(String name, String description, Money price, Long brandId) { + this.name = name; + this.description = description; + this.price = price; + this.brandId = brandId; + this.likeCount = 0; + } + + public void update(String name, String description, Money price) { + this.name = name; + this.description = description; + this.price = price; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public Money price() { + return price; + } + + public Long brandId() { + return brandId; + } + + public int likeCount() { + return likeCount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..fb66f8a7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + ProductModel save(ProductModel product); + + Optional findById(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAll(Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..1bfe9a97c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,102 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final StockService stockService; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + BrandModel brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + ProductModel product = new ProductModel(name, description, price, brandId); + ProductModel saved = productRepository.save(product); + + stockService.create(saved.getId(), initialStock); + + return saved; + } + + @Transactional(readOnly = true) + public ProductModel getProduct(Long productId) { + ProductModel product = findById(productId); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return product; + } + + @Transactional(readOnly = true) + public ProductModel getProductForAdmin(Long productId) { + return findById(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); + if (brandId != null) { + return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, sortedPageable); + } + return productRepository.findAllByDeletedAtIsNull(sortedPageable); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + if (brandId != null) { + return productRepository.findAllByBrandId(brandId, pageable); + } + return productRepository.findAll(pageable); + } + + @Transactional + public ProductModel update(Long productId, String name, String description, Money price) { + ProductModel product = findById(productId); + product.update(name, description, price); + return product; + } + + @Transactional + public void delete(Long productId) { + ProductModel product = findById(productId); + product.delete(); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); + } + + public String getBrandName(Long brandId) { + return brandRepository.findById(brandId) + .map(brand -> brand.name().value()) + .orElse(null); + } + + private ProductModel findById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..cf0722075 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Sort; + +public enum ProductSortType { + LATEST(Sort.by(Sort.Direction.DESC, "createdAt")), + PRICE_ASC(Sort.by(Sort.Direction.ASC, "price.value")), + PRICE_DESC(Sort.by(Sort.Direction.DESC, "price.value")), + LIKES_DESC(Sort.by(Sort.Direction.DESC, "likeCount")); + + private final Sort sort; + + ProductSortType(Sort sort) { + this.sort = sort; + } + + public Sort toSort() { + return sort; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java new file mode 100644 index 000000000..1eac7b79c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain.stock; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "stock") +public class StockModel extends BaseEntity { + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected StockModel() {} + + public StockModel(Long productId, int quantity) { + this.productId = productId; + this.quantity = quantity; + } + + public void decrease(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.quantity -= amount; + } + + public void increase(int amount) { + this.quantity += amount; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + public StockStatus toStatus() { + return StockStatus.from(this.quantity); + } + + public Long productId() { + return productId; + } + + public int quantity() { + return quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..968a9be83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.stock; + +import java.util.Optional; + +public interface StockRepository { + + StockModel save(StockModel stock); + + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java new file mode 100644 index 000000000..28b7a5e02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -0,0 +1,26 @@ +package com.loopers.domain.stock; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class StockService { + + private final StockRepository stockRepository; + + @Transactional + public StockModel create(Long productId, int quantity) { + StockModel stock = new StockModel(productId, quantity); + return stockRepository.save(stock); + } + + @Transactional(readOnly = true) + public StockModel getByProductId(Long productId) { + return stockRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์žฌ๊ณ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java new file mode 100644 index 000000000..a02da2a9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockStatus.java @@ -0,0 +1,13 @@ +package com.loopers.domain.stock; + +public enum StockStatus { + IN_STOCK, + LOW_STOCK, + OUT_OF_STOCK; + + public static StockStatus from(int quantity) { + if (quantity <= 0) return OUT_OF_STOCK; + if (quantity <= 10) return LOW_STOCK; + return IN_STOCK; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..75c34e5d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..2b63af7da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandId(brandId, pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..bd61eabae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StockJpaRepository extends JpaRepository { + + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..f2d91d987 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + + @Override + public Optional findByProductId(Long productId) { + return stockJpaRepository.findByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..9fd941dd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductService productService; + private final StockService stockService; + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSortType sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page products = productService.getProducts(brandId, sort, PageRequest.of(page, size)); + Page response = products.map(product -> { + String brandName = productService.getBrandName(product.brandId()); + var stockStatus = stockService.getByProductId(product.getId()).toStatus(); + return ProductV1Dto.ProductResponse.from(product, brandName, stockStatus); + }); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductModel product = productService.getProduct(productId); + String brandName = productService.getBrandName(product.brandId()); + var stockStatus = stockService.getByProductId(product.getId()).toStatus(); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brandName, stockStatus)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..49c7f969c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockStatus; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus stockStatus + ) { + public static ProductResponse from(ProductModel model, String brandName, StockStatus stockStatus) { + return new ProductResponse( + model.getId(), + model.name(), + model.description(), + model.price().value(), + model.brandId(), + brandName, + model.likeCount(), + stockStatus + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java new file mode 100644 index 000000000..b8b0e6c42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -0,0 +1,82 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller { + + private final ProductService productService; + private final StockService stockService; + + @PostMapping + public ApiResponse create( + @RequestBody ProductAdminV1Dto.CreateRequest request + ) { + ProductModel product = productService.register( + request.name(), request.description(), new Money(request.price()), + request.brandId(), request.initialStock() + ); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) Long brandId + ) { + Page result = productService.getProductsForAdmin(brandId, PageRequest.of(page, size)); + Page response = result.map(product -> { + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity()); + }); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductModel product = productService.getProductForAdmin(productId); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @PathVariable Long productId, + @RequestBody ProductAdminV1Dto.UpdateRequest request + ) { + ProductModel product = productService.update(productId, request.name(), request.description(), new Money(request.price())); + String brandName = productService.getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@PathVariable Long productId) { + productService.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java new file mode 100644 index 000000000..9399aba3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.product.admin; + +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public class ProductAdminV1Dto { + + public record CreateRequest( + String name, + String description, + int price, + Long brandId, + int initialStock + ) {} + + public record UpdateRequest( + String name, + String description, + int price + ) {} + + public record ProductResponse( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ProductResponse from(ProductModel model, String brandName, int stockQuantity) { + return new ProductResponse( + model.getId(), + model.name(), + model.description(), + model.price().value(), + model.brandId(), + brandName, + model.likeCount(), + stockQuantity, + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index 6577b447d..c939dbb29 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -1,5 +1,8 @@ package com.loopers.domain.brand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -31,11 +34,14 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; + @Mock + private ProductRepository productRepository; + private BrandService brandService; @BeforeEach void setUp() { - brandService = new BrandService(brandRepository); + brandService = new BrandService(brandRepository, productRepository); } @DisplayName("๋ธŒ๋žœ๋“œ ๋“ฑ๋ก") @@ -241,6 +247,7 @@ void softDeletesSuccessfully() { Long brandId = 1L; BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of()); // when brandService.delete(brandId); @@ -249,6 +256,28 @@ void softDeletesSuccessfully() { assertThat(brand.getDeletedAt()).isNotNull(); } + @DisplayName("์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ๋„ ์—ฐ์‡„ soft delete ํ•œ๋‹ค") + @Test + void cascadeSoftDeletesProducts() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + ProductModel product1 = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId); + ProductModel product2 = new ProductModel("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); + + // when + brandService.delete(brandId); + + // then + assertAll( + () -> assertThat(brand.getDeletedAt()).isNotNull(), + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") @Test void throwsWhenNotFound() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..5343c7e77 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MoneyTest { + + @DisplayName("Money ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์Œ์ˆ˜๋กœ ์ƒ์„ฑํ•˜๋ฉด BAD_REQUEST ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsOnNegativeValue() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("0์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithZero() { + // given & when + Money money = new Money(0); + + // then + assertThat(money.value()).isEqualTo(0); + } + + @DisplayName("์–‘์ˆ˜๋กœ ์ƒ์„ฑํ•˜๋ฉด value()๋กœ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void createsWithPositiveValue() { + // given & when + Money money = new Money(10000); + + // then + assertThat(money.value()).isEqualTo(10000); + } + } + + @DisplayName("Money ์—ฐ์‚ฐ") + @Nested + class Operations { + + @DisplayName("add()๋กœ ๋‘ Money๋ฅผ ํ•ฉ์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void addsTwo() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when + Money result = a.add(b); + + // then + assertThat(result.value()).isEqualTo(3000); + } + + @DisplayName("multiply()๋กœ Money์— ์ •์ˆ˜๋ฅผ ๊ณฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void multipliesByInt() { + // given + Money money = new Money(1000); + + // when + Money result = money.multiply(3); + + // then + assertThat(result.value()).isEqualTo(3000); + } + } + + @DisplayName("Money ๋™๋“ฑ์„ฑ") + @Nested + class Equality { + + @DisplayName("๊ฐ™์€ ๊ฐ’์˜ Money๋Š” ๋™๋“ฑํ•˜๋‹ค") + @Test + void equalsWithSameValue() { + // given + Money a = new Money(1000); + Money b = new Money(1000); + + // when & then + assertAll( + () -> assertThat(a).isEqualTo(b), + () -> assertThat(a.hashCode()).isEqualTo(b.hashCode()) + ); + } + + @DisplayName("๋‹ค๋ฅธ ๊ฐ’์˜ Money๋Š” ๋™๋“ฑํ•˜์ง€ ์•Š๋‹ค") + @Test + void notEqualsWithDifferentValue() { + // given + Money a = new Money(1000); + Money b = new Money(2000); + + // when & then + assertThat(a).isNotEqualTo(b); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..938f3b1bf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductModelTest { + + @DisplayName("์ƒํ’ˆ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + String name = "์—์–ด๋งฅ์Šค 90"; + String description = "๋‚˜์ดํ‚ค ํด๋ž˜์‹ ๋Ÿฌ๋‹ํ™”"; + Money price = new Money(129000); + Long brandId = 1L; + + // when + ProductModel product = new ProductModel(name, description, price, brandId); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(name), + () -> assertThat(product.description()).isEqualTo(description), + () -> assertThat(product.price()).isEqualTo(price), + () -> assertThat(product.brandId()).isEqualTo(brandId), + () -> assertThat(product.likeCount()).isEqualTo(0) + ); + } + + @DisplayName("description์ด null์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithNullDescription() { + // given & when + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", null, new Money(129000), 1L); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo("์—์–ด๋งฅ์Šค 90"), + () -> assertThat(product.description()).isNull() + ); + } + } + + @DisplayName("์ƒํ’ˆ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("name, description, price๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void updatesNameDescriptionPrice() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + String newName = "์—์–ด๋งฅ์Šค 95"; + String newDescription = "๋‰ด ๋Ÿฌ๋‹ํ™”"; + Money newPrice = new Money(159000); + + // when + product.update(newName, newDescription, newPrice); + + // then + assertAll( + () -> assertThat(product.name()).isEqualTo(newName), + () -> assertThat(product.description()).isEqualTo(newDescription), + () -> assertThat(product.price()).isEqualTo(newPrice) + ); + } + } + + @DisplayName("likeCount ์ดˆ๊ธฐ๊ฐ’") + @Nested + class LikeCount { + + @DisplayName("์ƒ์„ฑ ์‹œ likeCount๋Š” 0์ด๋‹ค") + @Test + void defaultsToZero() { + // given & when + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..cba1a132f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,240 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private StockService stockService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name) { + return brandService.register(name, "์„ค๋ช…").getId(); + } + + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๋“ฑ๋กํ•˜๋ฉด ์ƒํ’ˆ๊ณผ ์žฌ๊ณ ๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค") + @Test + void createsProductAndStock() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + + // when + ProductModel result = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค 90"), + () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + + StockModel stock = stockService.getByProductId(result.getId()); + assertThat(stock.quantity()).isEqualTo(100); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + brandService.delete(brandId); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ƒํ’ˆ ์กฐํšŒ") + @Nested + class GetProduct { + + @DisplayName("์กด์žฌํ•˜๊ณ  ๋ฏธ์‚ญ์ œ ์ƒํƒœ๋ฉด ์ƒํ’ˆ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsProduct() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.getProduct(saved.getId()); + + // then + assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค 90"); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์ด๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + productService.delete(saved.getId()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ") + @Nested + class GetProducts { + + @DisplayName("๋ฏธ์‚ญ์ œ ์ƒํ’ˆ๋งŒ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsNotDeletedProducts() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + productService.register("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); + ProductModel deleted = productService.register("์‚ญ์ œ๋  ์ƒํ’ˆ", "์„ค๋ช…", new Money(99000), brandId, 10); + productService.delete(deleted.getId()); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId๋กœ ํ•„ํ„ฐ๋งํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("๋‚˜์ดํ‚ค"); + Long adidasId = createBrand("์•„๋””๋‹ค์Šค"); + productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), nikeId, 100); + productService.register("์Šˆํผ์Šคํƒ€", "์บ์ฃผ์–ผ", new Money(99000), adidasId, 50); + + // when + Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).name()).isEqualTo("์—์–ด๋งฅ์Šค 90"); + } + } + + @DisplayName("์ƒํ’ˆ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("name, description, price๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void updatesSuccessfully() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // when + ProductModel result = productService.update(saved.getId(), "์—์–ด๋งฅ์Šค 95", "๋‰ด ๋Ÿฌ๋‹ํ™”", new Money(159000)); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค 95"), + () -> assertThat(result.description()).isEqualTo("๋‰ด ๋Ÿฌ๋‹ํ™”"), + () -> assertThat(result.price()).isEqualTo(new Money(159000)) + ); + } + } + + @DisplayName("์ƒํ’ˆ ์‚ญ์ œ") + @Nested + class Delete { + + @DisplayName("soft delete ํ›„ customer ์กฐํšŒ์—์„œ ์ œ์™ธ๋œ๋‹ค") + @Test + void excludedFromCustomerQueryAfterDelete() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(saved.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("soft delete ํ›„ admin ์กฐํšŒ์—์„œ๋Š” ํฌํ•จ๋œ๋‹ค") + @Test + void includedInAdminQueryAfterDelete() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // when + productService.delete(saved.getId()); + + // then + ProductModel result = productService.getProductForAdmin(saved.getId()); + assertThat(result.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์ „์ฒด ์‚ญ์ œ") + @Nested + class DeleteAllByBrandId { + + @DisplayName("ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ์„ soft delete ํ•œ๋‹ค") + @Test + void softDeletesAllProducts() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + productService.register("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); + + // when + productService.deleteAllByBrandId(brandId); + + // then + Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..da4d8d6f1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,340 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @Mock + private StockService stockService; + + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(productRepository, brandRepository, stockService); + } + + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก") + @Nested + class Register { + + @DisplayName("์„ฑ๊ณตํ•˜๋ฉด ์ €์žฅ๋œ ProductModel์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsSavedProduct() { + // given + String name = "์—์–ด๋งฅ์Šค 90"; + String description = "๋Ÿฌ๋‹ํ™”"; + Money price = new Money(129000); + Long brandId = 1L; + int initialStock = 100; + + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + when(productRepository.save(any(ProductModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.register(name, description, price, brandId, initialStock); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.description()).isEqualTo(description), + () -> assertThat(result.price()).isEqualTo(price), + () -> assertThat(result.brandId()).isEqualTo(brandId) + ); + verify(productRepository).save(any(ProductModel.class)); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + brand.delete(); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenBrandNotFound() { + // given + Long brandId = 999L; + when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ƒํ’ˆ ์กฐํšŒ (Customer)") + @Nested + class GetProduct { + + @DisplayName("๋ฏธ์‚ญ์ œ ์ƒํ’ˆ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsProductWhenNotDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProduct(productId); + + // then + assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์ด๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenDeleted() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("๋ฏธ์กด์žฌ ์ƒํ’ˆ์ด๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> productService.getProduct(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ƒํ’ˆ ์กฐํšŒ (Admin)") + @Nested + class GetProductForAdmin { + + @DisplayName("์‚ญ์ œ ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsProductRegardlessOfDeletion() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + product.delete(); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getProductForAdmin(productId); + + // then + assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"); + } + } + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Customer)") + @Nested + class GetProducts { + + @DisplayName("brandId ์—†์ด ์กฐํšŒํ•˜๋ฉด ๋ฏธ์‚ญ์ œ ์ƒํ’ˆ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsNotDeletedProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L), + new ProductModel("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByDeletedAtIsNull(any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(null, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("brandId๋กœ ํ•„ํ„ฐ๋งํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + @Test + void filtersbyBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandIdAndDeletedAtIsNull(any(Long.class), any(Pageable.class))).thenReturn(page); + + // when + Page result = productService.getProducts(brandId, ProductSortType.LATEST, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Admin)") + @Nested + class GetProductsForAdmin { + + @DisplayName("์‚ญ์ œ ํฌํ•จํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + @Test + void returnsAllProducts() { + // given + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAll(pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(null, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @DisplayName("brandId๋กœ ํ•„ํ„ฐ๋งํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + @Test + void filtersByBrandId() { + // given + Long brandId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List products = List.of( + new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId) + ); + Page page = new PageImpl<>(products, pageable, products.size()); + when(productRepository.findAllByBrandId(brandId, pageable)).thenReturn(page); + + // when + Page result = productService.getProductsForAdmin(brandId, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("์ƒํ’ˆ ์ˆ˜์ •") + @Nested + class Update { + + @DisplayName("name, description, price๋ฅผ ๋ณ€๊ฒฝํ•œ๋‹ค") + @Test + void updatesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.update(productId, "์—์–ด๋งฅ์Šค 95", "๋‰ด ๋Ÿฌ๋‹ํ™”", new Money(159000)); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค 95"), + () -> assertThat(result.description()).isEqualTo("๋‰ด ๋Ÿฌ๋‹ํ™”"), + () -> assertThat(result.price()).isEqualTo(new Money(159000)) + ); + } + } + + @DisplayName("์ƒํ’ˆ ์‚ญ์ œ") + @Nested + class Delete { + + @DisplayName("soft delete ํ•œ๋‹ค") + @Test + void softDeletesSuccessfully() { + // given + Long productId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + productService.delete(productId); + + // then + assertThat(product.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์ „์ฒด ์‚ญ์ œ") + @Nested + class DeleteAllByBrandId { + + @DisplayName("ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ๋ชจ๋“  ์ƒํ’ˆ์„ soft delete ํ•œ๋‹ค") + @Test + void softDeletesAllByBrandId() { + // given + Long brandId = 1L; + ProductModel product1 = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId); + ProductModel product2 = new ProductModel("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId); + when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); + + // when + productService.deleteAllByBrandId(brandId); + + // then + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java new file mode 100644 index 000000000..fb2f56159 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.stock; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StockModelTest { + + @DisplayName("์žฌ๊ณ  ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given & when + StockModel stock = new StockModel(1L, 100); + + // then + assertAll( + () -> assertThat(stock.productId()).isEqualTo(1L), + () -> assertThat(stock.quantity()).isEqualTo(100) + ); + } + } + + @DisplayName("์žฌ๊ณ  ๊ฐ์†Œ") + @Nested + class Decrease { + + @DisplayName("์ถฉ๋ถ„ํ•œ ์ˆ˜๋Ÿ‰์ด๋ฉด ๊ฐ์†Œ์‹œํ‚จ๋‹ค") + @Test + void decreasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.decrease(30); + + // then + assertThat(stock.quantity()).isEqualTo(70); + } + + @DisplayName("์ˆ˜๋Ÿ‰์„ ์ดˆ๊ณผํ•˜๋ฉด BAD_REQUEST ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenExceedsQuantity() { + // given + StockModel stock = new StockModel(1L, 10); + + // when + CoreException result = assertThrows(CoreException.class, () -> stock.decrease(11)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์žฌ๊ณ  ์ฆ๊ฐ€") + @Nested + class Increase { + + @DisplayName("์ˆ˜๋Ÿ‰์„ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค") + @Test + void increasesQuantity() { + // given + StockModel stock = new StockModel(1L, 100); + + // when + stock.increase(50); + + // then + assertThat(stock.quantity()).isEqualTo(150); + } + } + + @DisplayName("์žฌ๊ณ  ์ถฉ๋ถ„ ์—ฌ๋ถ€ ํ™•์ธ") + @Nested + class HasEnough { + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์ถฉ๋ถ„ํ•˜๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsTrueWhenEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ๋ถ€์กฑํ•˜๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsFalseWhenNotEnough() { + // given + StockModel stock = new StockModel(1L, 10); + + // when & then + assertThat(stock.hasEnough(11)).isFalse(); + } + } + + @DisplayName("์žฌ๊ณ  ์ƒํƒœ ๋ณ€ํ™˜") + @Nested + class ToStatus { + + @DisplayName("์ˆ˜๋Ÿ‰์ด 11 ์ด์ƒ์ด๋ฉด IN_STOCK์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsInStock() { + // given + StockModel stock = new StockModel(1L, 11); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.IN_STOCK); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1~10์ด๋ฉด LOW_STOCK์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsLowStock() { + // given + StockModel stock = new StockModel(1L, 5); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.LOW_STOCK); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด OUT_OF_STOCK์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsOutOfStock() { + // given + StockModel stock = new StockModel(1L, 0); + + // when & then + assertThat(stock.toStatus()).isEqualTo(StockStatus.OUT_OF_STOCK); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java new file mode 100644 index 000000000..b5e0d13c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -0,0 +1,97 @@ +package com.loopers.domain.stock; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StockServiceTest { + + @Mock + private StockRepository stockRepository; + + private StockService stockService; + + @BeforeEach + void setUp() { + stockService = new StockService(stockRepository); + } + + @DisplayName("์žฌ๊ณ  ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("productId์™€ quantity๋กœ ์žฌ๊ณ ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") + @Test + void createsStock() { + // given + Long productId = 1L; + int quantity = 100; + when(stockRepository.save(any(StockModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + StockModel result = stockService.create(productId, quantity); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(quantity) + ); + verify(stockRepository).save(any(StockModel.class)); + } + } + + @DisplayName("์ƒํ’ˆ๋ณ„ ์žฌ๊ณ  ์กฐํšŒ") + @Nested + class GetByProductId { + + @DisplayName("์กด์žฌํ•˜๋ฉด ์žฌ๊ณ ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsStockWhenExists() { + // given + Long productId = 1L; + StockModel stock = new StockModel(productId, 100); + when(stockRepository.findByProductId(productId)).thenReturn(Optional.of(stock)); + + // when + StockModel result = stockService.getByProductId(productId); + + // then + assertAll( + () -> assertThat(result.productId()).isEqualTo(productId), + () -> assertThat(result.quantity()).isEqualTo(100) + ); + } + + @DisplayName("๋ฏธ์กด์žฌ ์‹œ NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenNotFound() { + // given + Long productId = 999L; + when(stockRepository.findByProductId(productId)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, + () -> stockService.getByProductId(productId)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..8694da652 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -0,0 +1,347 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String CUSTOMER_ENDPOINT = "/api/v1/products"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createBrand(String name) { + BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, "์„ค๋ช…"); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int initialStock) { + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "์„ค๋ช…", price, brandId, initialStock); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + private void deleteProduct(Long productId) { + testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + private void deleteBrand(Long brandId) { + testRestTemplate.exchange( + BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + new ParameterizedTypeReference>() {} + ); + } + + // ========== Customer API ========== + + @DisplayName("GET /api/v1/products") + @Nested + class CustomerGetProducts { + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋ฉด 200๊ณผ ์žฌ๊ณ ์ƒํƒœ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("brandId๋กœ ํ•„ํ„ฐ๋งํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + @Test + void filtersByBrandId() { + // given + Long nikeId = createBrand("๋‚˜์ดํ‚ค"); + Long adidasId = createBrand("์•„๋””๋‹ค์Šค"); + createProduct("์—์–ด๋งฅ์Šค 90", 129000, nikeId, 100); + createProduct("์Šˆํผ์Šคํƒ€", 99000, adidasId, 50); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "?brandId=" + nikeId + "&page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class CustomerGetProduct { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด 200๊ณผ ์žฌ๊ณ ์ƒํƒœ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithStockStatus() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().name()).isEqualTo("์—์–ด๋งฅ์Šค 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(response.getBody().data().stockStatus()).isNotNull() + ); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + deleteProduct(productId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("๋ฏธ์กด์žฌ ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + CUSTOMER_ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ========== Admin API ========== + + @DisplayName("POST /api-admin/v1/products") + @Nested + class AdminCreate { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๋“ฑ๋กํ•˜๋ฉด 200๊ณผ ์žฌ๊ณ ์ˆ˜๋Ÿ‰์„ ํฌํ•จํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithStockQuantity() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("์—์–ด๋งฅ์Šค 90"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenBrandDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + deleteBrand(brandId); + ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest( + "์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", 129000, brandId, 100 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class AdminGetAll { + + @DisplayName("์‚ญ์ œ ํฌํ•จํ•˜์—ฌ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200IncludingDeleted() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + Long deletedId = createProduct("์‚ญ์ œ๋  ์ƒํ’ˆ", 99000, brandId, 10); + deleteProduct(deletedId); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class AdminGetProduct { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("์—์–ด๋งฅ์Šค 90"), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class AdminUpdate { + + @DisplayName("์ˆ˜์ • ์„ฑ๊ณต ์‹œ 200๊ณผ ๋ณ€๊ฒฝ๋œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200WithUpdatedInfo() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + ProductAdminV1Dto.UpdateRequest request = new ProductAdminV1Dto.UpdateRequest( + "์—์–ด๋งฅ์Šค 95", "๋‰ด ๋Ÿฌ๋‹ํ™”", 159000 + ); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("์—์–ด๋งฅ์Šค 95"), + () -> assertThat(response.getBody().data().description()).isEqualTo("๋‰ด ๋Ÿฌ๋‹ํ™”"), + () -> assertThat(response.getBody().data().price()).isEqualTo(159000) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class AdminDelete { + + @DisplayName("์‚ญ์ œ ์„ฑ๊ณต ์‹œ 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns200OnSuccess() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค 90", 129000, brandId, 100); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๋ฏธ์กด์žฌ ์ƒํ’ˆ ์‚ญ์ œ ์‹œ 404๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns404WhenNotFound() { + // given & when + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..0647ce3e3 --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,45 @@ +### [Customer] ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +GET {{commerce-api}}/api/v1/products?page=0&size=10 + +### [Customer] ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ) +GET {{commerce-api}}/api/v1/products?brandId=1&sort=LATEST&page=0&size=10 + +### [Customer] ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ) +GET {{commerce-api}}/api/v1/products?sort=PRICE_ASC&page=0&size=10 + +### [Customer] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +GET {{commerce-api}}/api/v1/products/1 + +### [Admin] ์ƒํ’ˆ ๋“ฑ๋ก +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json + +{ + "name": "์—์–ด๋งฅ์Šค 90", + "description": "๋‚˜์ดํ‚ค ํด๋ž˜์‹ ๋Ÿฌ๋‹ํ™”", + "price": 129000, + "brandId": 1, + "initialStock": 100 +} + +### [Admin] ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/products?page=0&size=10 + +### [Admin] ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ) +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=10 + +### [Admin] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/products/1 + +### [Admin] ์ƒํ’ˆ ์ˆ˜์ • +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json + +{ + "name": "์—์–ด๋งฅ์Šค 95", + "description": "๋‰ด ๋Ÿฌ๋‹ํ™”", + "price": 159000 +} + +### [Admin] ์ƒํ’ˆ ์‚ญ์ œ +DELETE {{commerce-api}}/api-admin/v1/products/1 From ff71ce2f0b23cb1f5dd01161c17f1d2446d7a176 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 08:13:02 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LikeModel ์—”ํ‹ฐํ‹ฐ (userId, productId, soft delete) - LikeService, LikeRepository, LikeRepositoryImpl - LikeFacade: ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ + likeCount ๋น„์ •๊ทœํ™” - LikeV1Controller: POST/DELETE /api/v1/products/{productId}/likes, GET /api/v1/users/{userId}/likes - ProductModel: incrementLikeCount/decrementLikeCount ์ถ”๊ฐ€ (์Œ์ˆ˜ ๋ฐฉ์ง€) - ApiControllerAdvice: MissingRequestHeaderException ํ•ธ๋“ค๋Ÿฌ ์ถ”๊ฐ€ - ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ข‹์•„์š” ๋ชฉ๋ก ์ œ์™ธ (JPQL ์„œ๋ธŒ์ฟผ๋ฆฌ) - Unit 6 + Integration 9 + E2E 7 ํ…Œ์ŠคํŠธ ์ „์ฒด ํ†ต๊ณผ (์ด 222๊ฐœ/0์‹คํŒจ) Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 54 +++++ .../com/loopers/domain/like/LikeModel.java | 45 ++++ .../loopers/domain/like/LikeRepository.java | 17 ++ .../com/loopers/domain/like/LikeService.java | 31 +++ .../loopers/domain/product/ProductModel.java | 10 + .../like/LikeJpaRepository.java | 21 ++ .../like/LikeRepositoryImpl.java | 37 ++++ .../interfaces/api/ApiControllerAdvice.java | 7 + .../interfaces/api/like/LikeV1Controller.java | 69 ++++++ .../interfaces/api/like/LikeV1Dto.java | 27 +++ .../like/LikeFacadeIntegrationTest.java | 200 ++++++++++++++++++ .../loopers/domain/like/LikeModelTest.java | 58 +++++ .../domain/product/ProductModelTest.java | 43 +++- .../interfaces/api/like/LikeV1ApiE2ETest.java | 180 ++++++++++++++++ http/commerce-api/like-v1.http | 14 ++ 15 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java create mode 100644 http/commerce-api/like-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..14fcf40cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + @Transactional + public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); + + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + if (existing.isEmpty()) { + likeService.save(new LikeModel(userId, productId)); + product.incrementLikeCount(); + } else if (existing.get().getDeletedAt() != null) { + existing.get().restore(); + product.incrementLikeCount(); + } + // else: ์ด๋ฏธ ํ™œ์„ฑ ์ข‹์•„์š” ์กด์žฌ โ†’ ๋ฉฑ๋“ฑ, ์•„๋ฌด๊ฒƒ๋„ ์•ˆ ํ•จ + } + + @Transactional + public void unlike(Long userId, Long productId) { + Optional existing = likeService.findActiveLike(userId, productId); + + if (existing.isPresent()) { + existing.get().delete(); + ProductModel product = productService.getProduct(existing.get().productId()); + product.decrementLikeCount(); + } + // else: ์ข‹์•„์š”๊ฐ€ ์—†์Œ โ†’ ๋ฉฑ๋“ฑ, ์•„๋ฌด๊ฒƒ๋„ ์•ˆ ํ•จ + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long userId, Pageable pageable) { + return likeService.getMyLikes(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..d8cbe4a6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "likes") +public class LikeModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected LikeModel() {} + + public LikeModel(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public Long userId() { + return userId; + } + + public Long productId() { + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..e84f658f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeRepository { + + LikeModel save(LikeModel like); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..27571279b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + public LikeModel save(LikeModel like) { + return likeRepository.save(like); + } + + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + public Optional findActiveLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 4e455250b..84a5167c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -60,4 +60,14 @@ public Long brandId() { public int likeCount() { return likeCount; } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..d46e2c3d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId); + + @Query("SELECT l FROM LikeModel l WHERE l.userId = :userId AND l.deletedAt IS NULL " + + "AND EXISTS (SELECT 1 FROM ProductModel p WHERE p.id = l.productId AND p.deletedAt IS NULL)") + Page findActiveLikesWithActiveProduct(@Param("userId") Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..8143351fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductIdAndDeletedAtIsNull(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductIdAndDeletedAtIsNull(userId, productId); + } + + @Override + public Page findActiveLikesWithActiveProduct(Long userId, Pageable pageable) { + return likeJpaRepository.findActiveLikesWithActiveProduct(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..638f37300 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,12 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String message = String.format("ํ•„์ˆ˜ ํ—ค๋” '%s'์ด(๊ฐ€) ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", e.getHeaderName()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..4406f122a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + private final MemberAuthService memberAuthService; + private final ProductService productService; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse like( + @PathVariable Long productId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + likeFacade.like(member.getId(), productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse unlike( + @PathVariable Long productId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + likeFacade.unlike(member.getId(), productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getMyLikes( + @PathVariable Long userId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + MemberModel member = memberAuthService.authenticate(loginId, loginPw); + Page likes = likeFacade.getMyLikes(member.getId(), + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + Page response = likes.map(like -> { + ProductModel product = productService.getProduct(like.productId()); + return LikeV1Dto.LikeResponse.from(like, product); + }); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..b6a8a0aa4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + int productPrice, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeModel like, ProductModel product) { + return new LikeResponse( + like.getId(), + product.getId(), + product.name(), + product.price().value(), + like.getCreatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java new file mode 100644 index 000000000..c607834ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -0,0 +1,200 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class LikeFacadeIntegrationTest { + + @Autowired private LikeFacade likeFacade; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "์„ค๋ช…").getId(); } + private Long createProduct(String name, int price, Long brandId) { + return productService.register(name, "์„ค๋ช…", new Money(price), brandId, 10).getId(); + } + + @DisplayName("์ข‹์•„์š” ๋“ฑ๋ก") + @Nested + class Like { + + @DisplayName("์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•˜๋ฉด likeCount๊ฐ€ ์ฆ๊ฐ€ํ•œ๋‹ค") + @Test + void likesProductAndIncrementsCount() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("๊ฐ™์€ ์ƒํ’ˆ์— ๋‘ ๋ฒˆ ์ข‹์•„์š”ํ•ด๋„ likeCount๋Š” 1์ด๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + @Test + void likeIsIdempotent() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + + // when + likeFacade.like(1L, productId); + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ํ›„ ๋‹ค์‹œ ์ข‹์•„์š”ํ•˜๋ฉด ๋ณต์›๋œ๋‹ค") + @Test + void restoresAfterUnlike() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when + likeFacade.like(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(1); + } + } + + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ") + @Nested + class Unlike { + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด likeCount๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค") + @Test + void unlikeDecrementsCount() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + likeFacade.like(1L, productId); + + // when + likeFacade.unlike(1L, productId); + + // then + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์„ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + @Test + void unlikeIsIdempotent() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + + // when & then โ€” ์˜ˆ์™ธ ์—†์ด ์ •์ƒ ์™„๋ฃŒ + likeFacade.unlike(1L, productId); + } + + @DisplayName("์ด๋ฏธ ์ทจ์†Œํ•œ ์ข‹์•„์š”๋ฅผ ๋‹ค์‹œ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void doubleUnlikeIsIdempotent() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.unlike(1L, productId); + + // when & then โ€” ์˜ˆ์™ธ ์—†์ด ์ •์ƒ ์™„๋ฃŒ + likeFacade.unlike(1L, productId); + + ProductModel product = productService.getProduct(productId); + assertThat(product.likeCount()).isEqualTo(0); + } + } + + @DisplayName("์ข‹์•„์š” ๋ชฉ๋ก ์กฐํšŒ") + @Nested + class GetMyLikes { + + @DisplayName("๋ณธ์ธ์˜ ์ข‹์•„์š” ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void getsMyLikes() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(2); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์˜ ์ข‹์•„์š”๋Š” ๋ชฉ๋ก์—์„œ ์ œ์™ธ๋œ๋‹ค") + @Test + void excludesDeletedProducts() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId); + likeFacade.like(1L, p1); + likeFacade.like(1L, p2); + productService.delete(p2); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).productId()).isEqualTo(p1) + ); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ข‹์•„์š”๋Š” ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void doesNotReturnOtherUserLikes() { + // given + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + likeFacade.like(1L, productId); + likeFacade.like(2L, productId); + + // when + Page result = likeFacade.getMyLikes(1L, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(result.getContent()).hasSize(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..13b248539 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeModelTest { + + @DisplayName("์ข‹์•„์š” ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Long productId = 2L; + + // when + LikeModel like = new LikeModel(userId, productId); + + // then + assertAll( + () -> assertThat(like.userId()).isEqualTo(userId), + () -> assertThat(like.productId()).isEqualTo(productId) + ); + } + + @DisplayName("userId๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(null, 1L)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new LikeModel(1L, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 938f3b1bf..81ca675e8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -74,7 +74,7 @@ void updatesNameDescriptionPrice() { } } - @DisplayName("likeCount ์ดˆ๊ธฐ๊ฐ’") + @DisplayName("likeCount") @Nested class LikeCount { @@ -87,5 +87,46 @@ void defaultsToZero() { // then assertThat(product.likeCount()).isEqualTo(0); } + + @DisplayName("incrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด likeCount๊ฐ€ 1 ์ฆ๊ฐ€ํ•œ๋‹ค") + @Test + void incrementsLikeCount() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + + // when + product.incrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด likeCount๊ฐ€ 1 ๊ฐ์†Œํ•œ๋‹ค") + @Test + void decrementsLikeCount() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + product.incrementLikeCount(); + product.incrementLikeCount(); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("likeCount๊ฐ€ 0์ผ ๋•Œ decrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด 0์„ ์œ ์ง€ํ•œ๋‹ค") + @Test + void doesNotGoBelowZero() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..08f1d61aa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -0,0 +1,180 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "์„ค๋ช…"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId) { + var req = new ProductAdminV1Dto.CreateRequest(name, "์„ค๋ช…", price, brandId, 10); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "ํ…Œ์ŠคํŠธ์œ ์ €", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class LikeProduct { + + @DisplayName("์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•œ๋‹ค") + @Test + void likesProduct() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๊ฐ™์€ ์ƒํ’ˆ์— ๋‘ ๋ฒˆ ์ข‹์•„์š”ํ•ด๋„ 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + @Test + void likeIsIdempotent() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("์ธ์ฆ ์—†์ด ์ข‹์•„์š”ํ•˜๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns400WhenNoAuth() { + var response = testRestTemplate.exchange( + "/api/v1/products/1/likes", HttpMethod.POST, null, + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class UnlikeProduct { + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•œ๋‹ค") + @Test + void unlikesProduct() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + productId + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์„ ์ทจ์†Œํ•ด๋„ 200์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + @Test + void unlikeIsIdempotent() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.DELETE, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/users/{userId}/likes") + @Nested + class GetMyLikes { + + @DisplayName("๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + @Test + void getsMyLikes() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId); + signupMember(); + + testRestTemplate.exchange("/api/v1/products/" + p1 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + testRestTemplate.exchange("/api/v1/products/" + p2 + "/likes", + HttpMethod.POST, new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + var response = testRestTemplate.exchange( + "/api/v1/users/1/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..3cf72885f --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,14 @@ +### ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +GET {{commerce-api}}/api/v1/users/1/likes?page=0&size=10 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! From 51e953eb37edc1f51c8d9419bee65d1f8e8ab5f9 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 10:29:16 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @LoginMember: ArgumentResolver๋กœ X-Loopers-LoginId/Pw ํ—ค๋” ์ธ์ฆ ํ›„ MemberModel ์ฃผ์ž… - @AdminUser: X-Loopers-Ldap ํ—ค๋” ๊ฒ€์ฆ ํ›„ AdminInfo ์ฃผ์ž… - WebMvcConfig์— ArgumentResolver ๋“ฑ๋ก - MemberFacade์—์„œ ์ธ์ฆ ์ฑ…์ž„ ์ œ๊ฑฐ (ArgumentResolver๋กœ ์ด๋™) - ์ „์ฒด ์ปจํŠธ๋กค๋Ÿฌ ๋ฆฌํŒฉํ† ๋ง: ๋ฐ˜๋ณต์ ์ธ ์ธ์ฆ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ œ๊ฑฐ - Facade ๋ ˆ์ด์–ด ๋ถ„๋ฆฌ: BrandFacade, ProductFacade, LikeWithProduct ์ถ”๊ฐ€ - E2E ํ…Œ์ŠคํŠธ์— ์–ด๋“œ๋ฏผ LDAP ํ—ค๋” ์ ์šฉ Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 21 ++++ .../loopers/application/like/LikeFacade.java | 9 ++ .../application/like/LikeWithProduct.java | 9 ++ .../application/member/MemberFacade.java | 9 +- .../application/product/ProductDetail.java | 40 ++++++ .../application/product/ProductFacade.java | 77 ++++++++++++ .../java/com/loopers/config/WebMvcConfig.java | 24 ++++ .../loopers/domain/brand/BrandService.java | 8 -- .../domain/product/ProductService.java | 26 +--- .../brand/admin/BrandAdminV1Controller.java | 18 ++- .../interfaces/api/like/LikeV1Controller.java | 42 ++----- .../api/member/MemberV1ApiSpec.java | 9 +- .../api/member/MemberV1Controller.java | 22 ++-- .../api/product/ProductV1Controller.java | 25 ++-- .../interfaces/api/product/ProductV1Dto.java | 20 +-- .../admin/ProductAdminV1Controller.java | 49 ++++---- .../api/product/admin/ProductAdminV1Dto.java | 26 ++-- .../loopers/interfaces/auth/AdminInfo.java | 4 + .../loopers/interfaces/auth/AdminUser.java | 11 ++ .../auth/AdminUserArgumentResolver.java | 38 ++++++ .../loopers/interfaces/auth/LoginMember.java | 11 ++ .../auth/LoginMemberArgumentResolver.java | 39 ++++++ .../application/brand/BrandFacadeTest.java | 49 ++++++++ .../like/LikeFacadeIntegrationTest.java | 4 +- .../application/member/MemberFacadeTest.java | 11 +- .../product/ProductFacadeTest.java | 119 ++++++++++++++++++ .../domain/brand/BrandServiceTest.java | 31 +---- .../ProductServiceIntegrationTest.java | 36 +++--- .../domain/product/ProductServiceTest.java | 49 +------- .../interfaces/api/MemberV1ApiE2ETest.java | 18 +-- .../api/brand/BrandV1ApiE2ETest.java | 31 +++-- .../interfaces/api/like/LikeV1ApiE2ETest.java | 25 +++- .../api/product/ProductV1ApiE2ETest.java | 29 +++-- 33 files changed, 650 insertions(+), 289 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..da00d4164 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,21 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + @Transactional + public void delete(Long brandId) { + brandService.delete(brandId); + productService.deleteAllByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 14fcf40cb..3901151c0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -51,4 +51,13 @@ public void unlike(Long userId, Long productId) { public Page getMyLikes(Long userId, Pageable pageable) { return likeService.getMyLikes(userId, pageable); } + + @Transactional(readOnly = true) + public Page getMyLikesWithProducts(Long userId, Pageable pageable) { + Page likes = likeService.getMyLikes(userId, pageable); + return likes.map(like -> { + ProductModel product = productService.getProduct(like.productId()); + return new LikeWithProduct(like, product); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java new file mode 100644 index 000000000..5da08528b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeWithProduct.java @@ -0,0 +1,9 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.product.ProductModel; + +public record LikeWithProduct( + LikeModel like, + ProductModel product +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 75658e7e8..b06c9c47d 100644 --- 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 @@ -1,6 +1,5 @@ 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; @@ -14,7 +13,6 @@ public class MemberFacade { private final MemberSignupService memberSignupService; - private final MemberAuthService memberAuthService; private final MemberPasswordService memberPasswordService; public MemberInfo signup(String loginId, String password, String name, @@ -23,14 +21,11 @@ public MemberInfo signup(String loginId, String password, String name, return MemberInfo.from(member); } - public MemberInfo getMyInfo(String loginId, String password) { - MemberModel member = memberAuthService.authenticate(loginId, password); + public MemberInfo getMyInfo(MemberModel member) { return MemberInfo.fromWithMaskedName(member); } - public void changePassword(String loginId, String password, - String currentPassword, String newPassword) { - MemberModel member = memberAuthService.authenticate(loginId, password); + public void changePassword(MemberModel member, String currentPassword, String newPassword) { memberPasswordService.changePassword(member, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java new file mode 100644 index 000000000..d2dd60302 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockStatus; + +import java.time.ZonedDateTime; + +public record ProductDetail( + Long id, + String name, + String description, + int price, + Long brandId, + String brandName, + int likeCount, + StockStatus stockStatus, + int stockQuantity, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), stockStatus, 0, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } + + public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { + return new ProductDetail( + product.getId(), product.name(), product.description(), + product.price().value(), product.brandId(), brandName, + product.likeCount(), null, stockQuantity, + product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..1f5b0d8d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,77 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final StockService stockService; + + @Transactional + public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + brandService.getBrand(brandId); + ProductModel product = productService.register(name, description, price, brandId); + stockService.create(product.getId(), initialStock); + return product; + } + + @Transactional(readOnly = true) + public ProductDetail getProduct(Long productId) { + ProductModel product = productService.getProduct(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + } + + @Transactional(readOnly = true) + public ProductDetail getProductForAdmin(Long productId) { + ProductModel product = productService.getProductForAdmin(productId); + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(productId); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productService.getProducts(brandId, sortType, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + }); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + Page products = productService.getProductsForAdmin(brandId, pageable); + return products.map(product -> { + String brandName = getBrandName(product.brandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + }); + } + + private String getBrandName(Long brandId) { + try { + BrandModel brand = brandService.getBrandForAdmin(brandId); + return brand.name().value(); + } catch (Exception e) { + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..6e4cc110b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import com.loopers.interfaces.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AdminUserArgumentResolver adminUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginMemberArgumentResolver); + resolvers.add(adminUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index cbcb9d896..0691e2de2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,7 +1,5 @@ package com.loopers.domain.brand; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -10,14 +8,11 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @RequiredArgsConstructor @Component public class BrandService { private final BrandRepository brandRepository; - private final ProductRepository productRepository; @Transactional public BrandModel register(String name, String description) { @@ -64,9 +59,6 @@ public BrandModel update(Long brandId, String name, String description) { public void delete(Long brandId) { BrandModel brand = findById(brandId); brand.delete(); - - List products = productRepository.findAllByBrandId(brandId); - products.forEach(ProductModel::delete); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 1bfe9a97c..f1634d58e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,8 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,24 +16,11 @@ public class ProductService { private final ProductRepository productRepository; - private final BrandRepository brandRepository; - private final StockService stockService; @Transactional - public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { - BrandModel brand = brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - if (brand.getDeletedAt() != null) { - throw new CoreException(ErrorType.NOT_FOUND, "์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - + public ProductModel register(String name, String description, Money price, Long brandId) { ProductModel product = new ProductModel(name, description, price, brandId); - ProductModel saved = productRepository.save(product); - - stockService.create(saved.getId(), initialStock); - - return saved; + return productRepository.save(product); } @Transactional(readOnly = true) @@ -89,12 +73,6 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(ProductModel::delete); } - public String getBrandName(Long brandId) { - return brandRepository.findById(brandId) - .map(brand -> brand.name().value()) - .orElse(null); - } - private ProductModel findById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java index bf201ca3b..391cb54d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java @@ -1,8 +1,11 @@ package com.loopers.interfaces.api.brand.admin; +import com.loopers.application.brand.BrandFacade; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -22,9 +25,11 @@ public class BrandAdminV1Controller { private final BrandService brandService; + private final BrandFacade brandFacade; @PostMapping public ApiResponse create( + @AdminUser AdminInfo admin, @RequestBody BrandAdminV1Dto.CreateRequest request ) { BrandModel brand = brandService.register(request.name(), request.description()); @@ -33,21 +38,26 @@ public ApiResponse create( @GetMapping public ApiResponse> getAll( + @AdminUser AdminInfo admin, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { Page result = brandService.getAll(PageRequest.of(page, size)); return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); } @GetMapping("/{brandId}") - public ApiResponse getBrand(@PathVariable Long brandId) { + public ApiResponse getBrand( + @AdminUser AdminInfo admin, + @PathVariable Long brandId + ) { BrandModel brand = brandService.getBrandForAdmin(brandId); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(brand)); } @PutMapping("/{brandId}") public ApiResponse update( + @AdminUser AdminInfo admin, @PathVariable Long brandId, @RequestBody BrandAdminV1Dto.UpdateRequest request ) { @@ -56,8 +66,8 @@ public ApiResponse update( } @DeleteMapping("/{brandId}") - public ApiResponse delete(@PathVariable Long brandId) { - brandService.delete(brandId); + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { + brandFacade.delete(brandId); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 4406f122a..cf4ba7de5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,12 +1,12 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; -import com.loopers.domain.like.LikeModel; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.like.LikeWithProduct; import com.loopers.domain.member.MemberModel; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,46 +23,31 @@ public class LikeV1Controller { private final LikeFacade likeFacade; - private final MemberAuthService memberAuthService; - private final ProductService productService; @PostMapping("/api/v1/products/{productId}/likes") - public ApiResponse like( - @PathVariable Long productId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw - ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); + public ApiResponse like(@LoginMember MemberModel member, @PathVariable Long productId) { likeFacade.like(member.getId(), productId); return ApiResponse.success(null); } @DeleteMapping("/api/v1/products/{productId}/likes") - public ApiResponse unlike( - @PathVariable Long productId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw - ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); + public ApiResponse unlike(@LoginMember MemberModel member, @PathVariable Long productId) { likeFacade.unlike(member.getId(), productId); return ApiResponse.success(null); } @GetMapping("/api/v1/users/{userId}/likes") public ApiResponse> getMyLikes( + @LoginMember MemberModel member, @PathVariable Long userId, - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { - MemberModel member = memberAuthService.authenticate(loginId, loginPw); - Page likes = likeFacade.getMyLikes(member.getId(), + if (!member.getId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ณธ์ธ์˜ ์ข‹์•„์š” ๋ชฉ๋ก๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + Page likes = likeFacade.getMyLikesWithProducts(member.getId(), PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); - Page response = likes.map(like -> { - ProductModel product = productService.getProduct(like.productId()); - return LikeV1Dto.LikeResponse.from(like, product); - }); - return ApiResponse.success(response); + return ApiResponse.success(likes.map(lwp -> LikeV1Dto.LikeResponse.from(lwp.like(), lwp.product()))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 9cca8fd0b..625df737d 100644 --- 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 @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.member; +import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import static com.loopers.interfaces.api.member.MemberV1Dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -8,12 +10,11 @@ public interface MemberV1ApiSpec { @Operation(summary = "ํšŒ์›๊ฐ€์ž…", description = "์ƒˆ๋กœ์šด ํšŒ์›์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.") - ApiResponse signup(MemberV1Dto.SignupRequest request); + ApiResponse signup(SignupRequest request); @Operation(summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", description = "ํ—ค๋” ์ธ์ฆ์„ ํ†ตํ•ด ๋‚ด ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") - ApiResponse getMe(String loginId, String password); + ApiResponse getMe(MemberModel member); @Operation(summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", description = "๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") - ApiResponse changePassword(String loginId, String password, - MemberV1Dto.ChangePasswordRequest request); + ApiResponse changePassword(MemberModel member, ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index a3ee3eb2f..bcdcfd412 100644 --- 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 @@ -2,19 +2,20 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; +import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RestController -@RequestMapping("/api/v1/members") +@RequestMapping("/api/v1/users") public class MemberV1Controller implements MemberV1ApiSpec { private final MemberFacade memberFacade; @@ -33,23 +34,18 @@ public ApiResponse signup( @GetMapping("/me") @Override - public ApiResponse getMe( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ) { - MemberInfo info = memberFacade.getMyInfo(loginId, password); + public ApiResponse getMe(@LoginMember MemberModel member) { + MemberInfo info = memberFacade.getMyInfo(member); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } - @PatchMapping("/me/password") + @PutMapping("/password") @Override public ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, + @LoginMember MemberModel member, @RequestBody MemberV1Dto.ChangePasswordRequest request ) { - memberFacade.changePassword(loginId, password, - request.currentPassword(), request.newPassword()); + memberFacade.changePassword(member, request.currentPassword(), request.newPassword()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 9fd941dd0..c6cfd3907 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,9 +1,8 @@ package com.loopers.interfaces.api.product; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.ProductSortType; -import com.loopers.domain.stock.StockService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -19,30 +18,22 @@ @RequestMapping("/api/v1/products") public class ProductV1Controller { - private final ProductService productService; - private final StockService stockService; + private final ProductFacade productFacade; @GetMapping public ApiResponse> getProducts( @RequestParam(required = false) Long brandId, @RequestParam(defaultValue = "LATEST") ProductSortType sort, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size ) { - Page products = productService.getProducts(brandId, sort, PageRequest.of(page, size)); - Page response = products.map(product -> { - String brandName = productService.getBrandName(product.brandId()); - var stockStatus = stockService.getByProductId(product.getId()).toStatus(); - return ProductV1Dto.ProductResponse.from(product, brandName, stockStatus); - }); - return ApiResponse.success(response); + Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); + return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); } @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { - ProductModel product = productService.getProduct(productId); - String brandName = productService.getBrandName(product.brandId()); - var stockStatus = stockService.getByProductId(product.getId()).toStatus(); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brandName, stockStatus)); + ProductDetail detail = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 49c7f969c..29839c56d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product; -import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductDetail; import com.loopers.domain.stock.StockStatus; public class ProductV1Dto { @@ -15,16 +15,16 @@ public record ProductResponse( int likeCount, StockStatus stockStatus ) { - public static ProductResponse from(ProductModel model, String brandName, StockStatus stockStatus) { + public static ProductResponse from(ProductDetail detail) { return new ProductResponse( - model.getId(), - model.name(), - model.description(), - model.price().value(), - model.brandId(), - brandName, - model.likeCount(), - stockStatus + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockStatus() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java index b8b0e6c42..34b65cbc6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java @@ -1,11 +1,13 @@ package com.loopers.interfaces.api.product.admin; +import com.loopers.application.product.ProductDetail; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; -import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AdminInfo; +import com.loopers.interfaces.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -24,58 +26,55 @@ @RequestMapping("/api-admin/v1/products") public class ProductAdminV1Controller { + private final ProductFacade productFacade; private final ProductService productService; - private final StockService stockService; @PostMapping public ApiResponse create( + @AdminUser AdminInfo admin, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductModel product = productService.register( + ProductModel product = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.initialStock() ); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + ProductDetail detail = productFacade.getProductForAdmin(product.getId()); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @GetMapping public ApiResponse> getAll( + @AdminUser AdminInfo admin, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Long brandId ) { - Page result = productService.getProductsForAdmin(brandId, PageRequest.of(page, size)); - Page response = result.map(product -> { - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity()); - }); - return ApiResponse.success(response); + Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); + return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { - ProductModel product = productService.getProductForAdmin(productId); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + public ApiResponse getProduct( + @AdminUser AdminInfo admin, + @PathVariable Long productId + ) { + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @PutMapping("/{productId}") public ApiResponse update( + @AdminUser AdminInfo admin, @PathVariable Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - ProductModel product = productService.update(productId, request.name(), request.description(), new Money(request.price())); - String brandName = productService.getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(product, brandName, stock.quantity())); + productService.update(productId, request.name(), request.description(), new Money(request.price())); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); } @DeleteMapping("/{productId}") - public ApiResponse delete(@PathVariable Long productId) { + public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { productService.delete(productId); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java index 9399aba3e..5f58c5cb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product.admin; -import com.loopers.domain.product.ProductModel; +import com.loopers.application.product.ProductDetail; import java.time.ZonedDateTime; @@ -33,19 +33,19 @@ public record ProductResponse( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ProductResponse from(ProductModel model, String brandName, int stockQuantity) { + public static ProductResponse from(ProductDetail detail) { return new ProductResponse( - model.getId(), - model.name(), - model.description(), - model.price().value(), - model.brandId(), - brandName, - model.likeCount(), - stockQuantity, - model.getCreatedAt(), - model.getUpdatedAt(), - model.getDeletedAt() + detail.id(), + detail.name(), + detail.description(), + detail.price(), + detail.brandId(), + detail.brandName(), + detail.likeCount(), + detail.stockQuantity(), + detail.createdAt(), + detail.updatedAt(), + detail.deletedAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java new file mode 100644 index 000000000..516f8b1d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record AdminInfo(String ldap) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java new file mode 100644 index 000000000..3cf3df48e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java new file mode 100644 index 000000000..969bd5d7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String VALID_LDAP = "loopers.admin"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AdminUser.class) + && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String ldap = webRequest.getHeader("X-Loopers-Ldap"); + + if (ldap == null || ldap.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์–ด๋“œ๋ฏผ ์ธ์ฆ ํ—ค๋”๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + if (!VALID_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์–ด๋“œ๋ฏผ ์ธ์ฆ์ž…๋‹ˆ๋‹ค."); + } + + return new AdminInfo(ldap); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java new file mode 100644 index 000000000..93ea3e09a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..21898e1b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.auth; + +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberAuthService memberAuthService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginMember.class) + && MemberModel.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + String loginId = webRequest.getHeader("X-Loopers-LoginId"); + String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); + + if (loginId == null || loginPw == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ธ์ฆ ํ—ค๋”๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + return memberAuthService.authenticate(loginId, loginPw); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..34570fd9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,49 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + private BrandFacade brandFacade; + + @BeforeEach + void setUp() { + brandFacade = new BrandFacade(brandService, productService); + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์‚ญ์ œ") + @Nested + class Delete { + + @DisplayName("๋ธŒ๋žœ๋“œ soft delete ํ›„ ์†Œ์† ์ƒํ’ˆ์„ ์—ฐ์‡„ soft delete ํ•œ๋‹ค") + @Test + void deletesBrandAndCascadesProducts() { + // given + Long brandId = 1L; + + // when + brandFacade.delete(brandId); + + // then + verify(brandService).delete(brandId); + verify(productService).deleteAllByBrandId(brandId); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index c607834ff..5e1389470 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeModel; import com.loopers.domain.product.Money; @@ -23,6 +24,7 @@ class LikeFacadeIntegrationTest { @Autowired private LikeFacade likeFacade; + @Autowired private ProductFacade productFacade; @Autowired private ProductService productService; @Autowired private BrandService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -32,7 +34,7 @@ class LikeFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "์„ค๋ช…").getId(); } private Long createProduct(String name, int price, Long brandId) { - return productService.register(name, "์„ค๋ช…", new Money(price), brandId, 10).getId(); + return productFacade.register(name, "์„ค๋ช…", new Money(price), brandId, 10).getId(); } @DisplayName("์ข‹์•„์š” ๋“ฑ๋ก") 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 index 75353325b..acc6623ed 100644 --- 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 @@ -22,9 +22,6 @@ class MemberFacadeTest { @Mock private MemberSignupService memberSignupService; - @Mock - private MemberAuthService memberAuthService; - @Mock private MemberPasswordService memberPasswordService; @@ -32,7 +29,7 @@ class MemberFacadeTest { @BeforeEach void setUp() { - memberFacade = new MemberFacade(memberSignupService, memberAuthService, memberPasswordService); + memberFacade = new MemberFacade(memberSignupService, memberPasswordService); } @DisplayName("ํšŒ์›๊ฐ€์ž…") @@ -73,10 +70,9 @@ void returnsWithMaskedName() { 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!"); + MemberInfo result = memberFacade.getMyInfo(member); // then assertAll( @@ -98,10 +94,9 @@ void delegatesToPasswordService() { 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!"); + memberFacade.changePassword(member, "Current1!", "NewPass5678!"); // then verify(memberPasswordService).changePassword(member, "Current1!", "NewPass5678!"); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..311563551 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,119 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandName; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import com.loopers.domain.stock.StockStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private StockService stockService; + + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + productFacade = new ProductFacade(productService, brandService, stockService); + } + + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก") + @Nested + class Register { + + @DisplayName("๋ธŒ๋žœ๋“œ ๊ฒ€์ฆ ํ›„ ์ƒํ’ˆ ์ €์žฅ ๋ฐ ์žฌ๊ณ  ์ƒ์„ฑ์„ orchestrate ํ•œ๋‹ค") + @Test + void orchestratesRegistration() { + // given + Long brandId = 1L; + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ "); + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId); + + when(brandService.getBrand(brandId)).thenReturn(brand); + when(productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId)).thenReturn(product); + + // when + ProductModel result = productFacade.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> verify(brandService).getBrand(brandId), + () -> verify(productService).register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId), + () -> verify(stockService).create(any(), eq(100)) + ); + } + + @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @Test + void throwsWhenBrandDeleted() { + // given + Long brandId = 1L; + when(brandService.getBrand(brandId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + // when & then + assertThrows(CoreException.class, + () -> productFacade.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); + } + } + + @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (Customer)") + @Nested + class GetProduct { + + @DisplayName("์ƒํ’ˆ + ๋ธŒ๋žœ๋“œ๋ช… + ์žฌ๊ณ ์ƒํƒœ๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsProductDetail() { + // given + Long productId = 1L; + Long brandId = 1L; + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId); + BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ "); + StockModel stock = new StockModel(productId, 100); + + when(productService.getProduct(productId)).thenReturn(product); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(productId)).thenReturn(stock); + + // when + ProductDetail result = productFacade.getProduct(productId); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.stockStatus()).isEqualTo(StockStatus.IN_STOCK) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index c939dbb29..6577b447d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -1,8 +1,5 @@ package com.loopers.domain.brand; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.Money; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -34,14 +31,11 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; - @Mock - private ProductRepository productRepository; - private BrandService brandService; @BeforeEach void setUp() { - brandService = new BrandService(brandRepository, productRepository); + brandService = new BrandService(brandRepository); } @DisplayName("๋ธŒ๋žœ๋“œ ๋“ฑ๋ก") @@ -247,7 +241,6 @@ void softDeletesSuccessfully() { Long brandId = 1L; BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of()); // when brandService.delete(brandId); @@ -256,28 +249,6 @@ void softDeletesSuccessfully() { assertThat(brand.getDeletedAt()).isNotNull(); } - @DisplayName("์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ๋„ ์—ฐ์‡„ soft delete ํ•œ๋‹ค") - @Test - void cascadeSoftDeletesProducts() { - // given - Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); - ProductModel product1 = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId); - ProductModel product2 = new ProductModel("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(brandId)).thenReturn(List.of(product1, product2)); - - // when - brandService.delete(brandId); - - // then - assertAll( - () -> assertThat(brand.getDeletedAt()).isNotNull(), - () -> assertThat(product1.getDeletedAt()).isNotNull(), - () -> assertThat(product2.getDeletedAt()).isNotNull() - ); - } - @DisplayName("๋ฏธ์กด์žฌ ๋ธŒ๋žœ๋“œ๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") @Test void throwsWhenNotFound() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index cba1a132f..74174b1ca 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.application.product.ProductFacade; import com.loopers.domain.brand.BrandService; import com.loopers.domain.stock.StockModel; import com.loopers.domain.stock.StockService; @@ -25,6 +26,9 @@ class ProductServiceIntegrationTest { @Autowired private ProductService productService; + @Autowired + private ProductFacade productFacade; + @Autowired private BrandService brandService; @@ -43,6 +47,10 @@ private Long createBrand(String name) { return brandService.register(name, "์„ค๋ช…").getId(); } + private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + return productFacade.register(name, description, price, brandId, initialStock); + } + @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก") @Nested class Register { @@ -54,7 +62,7 @@ void createsProductAndStock() { Long brandId = createBrand("๋‚˜์ดํ‚ค"); // when - ProductModel result = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel result = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); // then assertAll( @@ -77,7 +85,7 @@ void throwsWhenBrandDeleted() { // when CoreException result = assertThrows(CoreException.class, - () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); + () -> createProduct("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -93,7 +101,7 @@ class GetProduct { void returnsProduct() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel saved = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); // when ProductModel result = productService.getProduct(saved.getId()); @@ -107,7 +115,7 @@ void returnsProduct() { void throwsWhenDeleted() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel saved = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); productService.delete(saved.getId()); // when @@ -128,9 +136,9 @@ class GetProducts { void returnsNotDeletedProducts() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); - productService.register("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); - ProductModel deleted = productService.register("์‚ญ์ œ๋  ์ƒํ’ˆ", "์„ค๋ช…", new Money(99000), brandId, 10); + createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + createProduct("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); + ProductModel deleted = createProduct("์‚ญ์ œ๋  ์ƒํ’ˆ", "์„ค๋ช…", new Money(99000), brandId, 10); productService.delete(deleted.getId()); // when @@ -146,8 +154,8 @@ void filtersByBrandId() { // given Long nikeId = createBrand("๋‚˜์ดํ‚ค"); Long adidasId = createBrand("์•„๋””๋‹ค์Šค"); - productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), nikeId, 100); - productService.register("์Šˆํผ์Šคํƒ€", "์บ์ฃผ์–ผ", new Money(99000), adidasId, 50); + createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), nikeId, 100); + createProduct("์Šˆํผ์Šคํƒ€", "์บ์ฃผ์–ผ", new Money(99000), adidasId, 50); // when Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); @@ -167,7 +175,7 @@ class Update { void updatesSuccessfully() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel saved = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); // when ProductModel result = productService.update(saved.getId(), "์—์–ด๋งฅ์Šค 95", "๋‰ด ๋Ÿฌ๋‹ํ™”", new Money(159000)); @@ -190,7 +198,7 @@ class Delete { void excludedFromCustomerQueryAfterDelete() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel saved = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); // when productService.delete(saved.getId()); @@ -206,7 +214,7 @@ void excludedFromCustomerQueryAfterDelete() { void includedInAdminQueryAfterDelete() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - ProductModel saved = productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + ProductModel saved = createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); // when productService.delete(saved.getId()); @@ -226,8 +234,8 @@ class DeleteAllByBrandId { void softDeletesAllProducts() { // given Long brandId = createBrand("๋‚˜์ดํ‚ค"); - productService.register("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); - productService.register("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); + createProduct("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100); + createProduct("์—์–ด๋งฅ์Šค 95", "๋Ÿฌ๋‹ํ™”", new Money(159000), brandId, 50); // when productService.deleteAllByBrandId(brandId); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index da4d8d6f1..9b173dd26 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -1,9 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -34,17 +30,11 @@ class ProductServiceTest { @Mock private ProductRepository productRepository; - @Mock - private BrandRepository brandRepository; - - @Mock - private StockService stockService; - private ProductService productService; @BeforeEach void setUp() { - productService = new ProductService(productRepository, brandRepository, stockService); + productService = new ProductService(productRepository); } @DisplayName("์ƒํ’ˆ ๋“ฑ๋ก") @@ -59,15 +49,12 @@ void returnsSavedProduct() { String description = "๋Ÿฌ๋‹ํ™”"; Money price = new Money(129000); Long brandId = 1L; - int initialStock = 100; - BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); when(productRepository.save(any(ProductModel.class))) .thenAnswer(invocation -> invocation.getArgument(0)); // when - ProductModel result = productService.register(name, description, price, brandId, initialStock); + ProductModel result = productService.register(name, description, price, brandId); // then assertAll( @@ -78,38 +65,6 @@ void returnsSavedProduct() { ); verify(productRepository).save(any(ProductModel.class)); } - - @DisplayName("์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") - @Test - void throwsWhenBrandDeleted() { - // given - Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); - brand.delete(); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - - // when - CoreException result = assertThrows(CoreException.class, - () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); - - // then - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ์— ๋“ฑ๋กํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") - @Test - void throwsWhenBrandNotFound() { - // given - Long brandId = 999L; - when(brandRepository.findById(brandId)).thenReturn(Optional.empty()); - - // when - CoreException result = assertThrows(CoreException.class, - () -> productService.register("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), brandId, 100)); - - // then - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } } @DisplayName("์ƒํ’ˆ ์กฐํšŒ (Customer)") 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 index b79abda30..12ed55563 100644 --- 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 @@ -26,9 +26,9 @@ @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 static final String ENDPOINT_SIGNUP = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; private final TestRestTemplate testRestTemplate; private final MemberJpaRepository memberJpaRepository; @@ -72,7 +72,7 @@ private HttpHeaders authHeaders(String loginId, String password) { return headers; } - @DisplayName("POST /api/v1/members") + @DisplayName("POST /api/v1/users") @Nested class Signup { @@ -140,7 +140,7 @@ void returns400OnInvalidLoginId() { } } - @DisplayName("GET /api/v1/members/me") + @DisplayName("GET /api/v1/users/me") @Nested class GetMe { @@ -186,7 +186,7 @@ void returns401OnWrongPassword() { } } - @DisplayName("PATCH /api/v1/members/me/password") + @DisplayName("PUT /api/v1/users/password") @Nested class ChangePassword { @@ -202,7 +202,7 @@ void returns200OnValidRequest() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); @@ -222,7 +222,7 @@ void returns400OnWrongCurrentPassword() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); @@ -242,7 +242,7 @@ void returns400OnInvalidNewPassword() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PUT, new HttpEntity<>(request, authHeaders("kwonmo", "Test1234!")), responseType); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index b71af55aa..9c4f78e12 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -12,6 +12,7 @@ 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; @@ -40,10 +41,16 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + private Long createBrand(String name, String description) { BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, description); ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -51,7 +58,7 @@ private Long createBrand(String name, String description) { private void deleteBrand(Long brandId) { testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } @@ -128,7 +135,7 @@ void returns200WithBrandInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -150,7 +157,7 @@ void returns409OnDuplicateName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -166,7 +173,7 @@ void returns400OnEmptyName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -188,7 +195,7 @@ void returns200WithPagedList() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -209,7 +216,7 @@ void returns200WithBrandInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -225,7 +232,7 @@ void returns200WithBrandInfo() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.GET, null, + ADMIN_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -247,7 +254,7 @@ void returns200WithUpdatedInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -269,7 +276,7 @@ void returns409OnDuplicateName() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + targetId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -290,7 +297,7 @@ void returns200OnSuccess() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -303,7 +310,7 @@ void returns200OnSuccess() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index 08f1d61aa..67152c396 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -35,22 +35,28 @@ public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp datab @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-Ldap", "loopers.admin"); + return h; + } + private Long createBrand(String name) { var req = new BrandAdminV1Dto.CreateRequest(name, "์„ค๋ช…"); - return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), new ParameterizedTypeReference>() {}).getBody().data().id(); } private Long createProduct(String name, int price, Long brandId) { var req = new ProductAdminV1Dto.CreateRequest(name, "์„ค๋ช…", price, brandId, 10); - return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "ํ…Œ์ŠคํŠธ์œ ์ €", LocalDate.of(1998, 1, 1), "test@example.com"); - testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + testRestTemplate.exchange("/api/v1/users", HttpMethod.POST, new HttpEntity<>(req), new ParameterizedTypeReference>() {}); } @@ -176,5 +182,18 @@ void getsMyLikes() { assertTrue(response.getStatusCode().is2xxSuccessful()); } + + @DisplayName("๋‹ค๋ฅธ ์œ ์ €์˜ ์ข‹์•„์š” ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋ฉด 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returns400WhenUserIdMismatch() { + signupMember(); + + var response = testRestTemplate.exchange( + "/api/v1/users/999/likes", HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index 8694da652..d2ae0befe 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -13,6 +13,7 @@ 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; @@ -42,10 +43,16 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + private Long createBrand(String name) { BrandAdminV1Dto.CreateRequest request = new BrandAdminV1Dto.CreateRequest(name, "์„ค๋ช…"); ResponseEntity> response = testRestTemplate.exchange( - BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + BRAND_ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -54,7 +61,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId, int initialStock) { ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "์„ค๋ช…", price, brandId, initialStock); ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); return response.getBody().data().id(); @@ -62,14 +69,14 @@ private Long createProduct(String name, int price, Long brandId, int initialStoc private void deleteProduct(Long productId) { testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } private void deleteBrand(Long brandId) { testRestTemplate.exchange( - BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, null, + BRAND_ADMIN_ENDPOINT + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference>() {} ); } @@ -194,7 +201,7 @@ void returns200WithStockQuantity() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -221,7 +228,7 @@ void returns404WhenBrandDeleted() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -245,7 +252,7 @@ void returns200IncludingDeleted() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + ADMIN_ENDPOINT + "?page=0&size=10", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -267,7 +274,7 @@ void returns200() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -296,7 +303,7 @@ void returns200WithUpdatedInfo() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request), + ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -323,7 +330,7 @@ void returns200OnSuccess() { // when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -336,7 +343,7 @@ void returns200OnSuccess() { void returns404WhenNotFound() { // given & when ResponseEntity> response = testRestTemplate.exchange( - ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, null, + ADMIN_ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); From 848a34c943dbb74221217e4cfbd3b506bad7c9de Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 27 Feb 2026 10:43:19 +0900 Subject: [PATCH 19/19] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20CRUD=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderModel/OrderItemModel ์—”ํ‹ฐํ‹ฐ, OrderStatus ์ƒํƒœ ์ „์ด - OrderService, OrderFacade (์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ ์—ฐ๋™) - OrderV1Controller (๊ณ ๊ฐ), OrderAdminV1Controller (์–ด๋“œ๋ฏผ) - ๋‹จ์œ„/ํ†ตํ•ฉ/E2E ํ…Œ์ŠคํŠธ ํฌํ•จ (239 tests, 0 failures) Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 38 ++ .../loopers/application/brand/BrandInfo.java | 26 ++ .../application/order/OrderFacade.java | 61 ++++ .../application/order/OrderItemCommand.java | 4 + .../application/order/OrderResult.java | 16 + .../loopers/domain/brand/BrandRepository.java | 3 + .../loopers/domain/order/OrderItemModel.java | 83 +++++ .../domain/order/OrderItemRepository.java | 12 + .../com/loopers/domain/order/OrderModel.java | 55 +++ .../loopers/domain/order/OrderRepository.java | 21 ++ .../loopers/domain/order/OrderService.java | 64 ++++ .../com/loopers/domain/order/OrderStatus.java | 9 + .../com/loopers/domain/product/Money.java | 2 + .../loopers/domain/product/ProductModel.java | 10 + .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 17 + .../loopers/domain/stock/StockRepository.java | 3 + .../loopers/domain/stock/StockService.java | 11 + .../brand/BrandJpaRepository.java | 3 + .../brand/BrandRepositoryImpl.java | 6 + .../order/OrderItemJpaRepository.java | 11 + .../order/OrderItemRepositoryImpl.java | 30 ++ .../order/OrderJpaRepository.java | 18 + .../order/OrderRepositoryImpl.java | 41 +++ .../product/ProductJpaRepository.java | 2 + .../product/ProductRepositoryImpl.java | 5 + .../stock/StockJpaRepository.java | 3 + .../stock/StockRepositoryImpl.java | 6 + .../interfaces/api/ApiControllerAdvice.java | 8 + .../api/order/OrderV1Controller.java | 74 ++++ .../interfaces/api/order/OrderV1Dto.java | 95 +++++ .../order/admin/OrderAdminV1Controller.java | 43 +++ .../api/order/admin/OrderAdminV1Dto.java | 72 ++++ .../order/OrderFacadeIntegrationTest.java | 174 +++++++++ .../domain/order/OrderItemModelTest.java | 111 ++++++ .../loopers/domain/order/OrderModelTest.java | 70 ++++ .../domain/product/ProductModelTest.java | 43 ++- .../domain/product/ProductServiceTest.java | 79 ++++ .../domain/stock/StockServiceTest.java | 28 ++ .../api/order/OrderV1ApiE2ETest.java | 195 ++++++++++ .../design/mermaid/00-ddd-design-framework.md | 337 ++++++++++++++++++ http/commerce-api/order-v1.http | 28 ++ 42 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java create mode 100644 docs/design/mermaid/00-ddd-design-framework.md create mode 100644 http/commerce-api/order-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..63e5d7b91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,38 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + + public BrandInfo register(String name, String description) { + return BrandInfo.from(brandService.register(name, description)); + } + + public BrandInfo getBrand(Long brandId) { + return BrandInfo.from(brandService.getBrand(brandId)); + } + + public BrandInfo getBrandForAdmin(Long brandId) { + return BrandInfo.from(brandService.getBrandForAdmin(brandId)); + } + + public BrandInfo update(Long brandId, String name, String description) { + return BrandInfo.from(brandService.update(brandId, name, description)); + } + + public void delete(Long brandId) { + brandService.delete(brandId); + } + + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..4a7db0896 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +import java.time.ZonedDateTime; + +public record BrandInfo( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + + public static BrandInfo from(BrandModel model) { + return new BrandInfo( + model.getId(), + model.name().value(), + model.description(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..58f867cef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final StockService stockService; + + @Transactional + public OrderResult placeOrder(Long userId, List commands) { + Money totalAmount = Money.ZERO; + List snapshots = new ArrayList<>(); + + for (OrderItemCommand cmd : commands) { + ProductModel product = productService.getProduct(cmd.productId()); + + StockModel stock = stockService.getByProductId(cmd.productId()); + stock.decrease(cmd.quantity()); + + Money subtotal = product.price().multiply(cmd.quantity()); + totalAmount = totalAmount.add(subtotal); + + snapshots.add(new SnapshotHolder( + product.getId(), product.name(), product.price(), cmd.quantity() + )); + } + + OrderModel order = orderService.save(new OrderModel(userId, totalAmount)); + + List items = snapshots.stream() + .map(s -> new OrderItemModel( + order.getId(), s.productId(), s.productName(), s.productPrice(), s.quantity() + )) + .toList(); + + List savedItems = orderService.saveAllItems(items); + + return OrderResult.of(order, savedItems); + } + + private record SnapshotHolder( + Long productId, String productName, Money productPrice, int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..58c8ef391 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, int quantity) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java new file mode 100644 index 000000000..56af5b560 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderResult.java @@ -0,0 +1,16 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.util.List; + +public record OrderResult( + OrderModel order, + List items +) { + + public static OrderResult of(OrderModel order, List items) { + return new OrderResult(order, items); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 26a0febef..f83f3ec35 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface BrandRepository { @@ -14,4 +15,6 @@ public interface BrandRepository { Optional findByName(String name); Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..8e0b81dc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,83 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "order_item") +public class OrderItemModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "product_price", nullable = false)) + private Money productPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItemModel() { + } + + public OrderItemModel(Long orderId, Long productId, String productName, Money productPrice, int quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + guard(); + } + + @Override + protected void guard() { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (productPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public Money subtotal() { + return productPrice.multiply(quantity); + } + + public Long orderId() { + return orderId; + } + + public Long productId() { + return productId; + } + + public String productName() { + return productName; + } + + public Money productPrice() { + return productPrice; + } + + public int quantity() { + return quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..7fce7486b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + OrderItemModel save(OrderItemModel orderItem); + + List saveAll(List orderItems); + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..9c9b89de8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + protected OrderModel() { + } + + public OrderModel(Long userId, Money totalAmount) { + this.userId = userId; + this.status = OrderStatus.CREATED; + this.totalAmount = totalAmount; + guard(); + } + + @Override + protected void guard() { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ์ž ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ด์•ก์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public Long userId() { + return userId; + } + + public OrderStatus status() { + return status; + } + + public Money totalAmount() { + return totalAmount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..23a12f9bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + OrderModel save(OrderModel order); + + Optional findById(Long id); + + List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..d779d2ea1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel save(OrderModel order) { + return orderRepository.save(order); + } + + @Transactional + public List saveAllItems(List orderItems) { + return orderItemRepository.saveAll(orderItems); + } + + @Transactional(readOnly = true) + public OrderModel getOrder(Long orderId, Long userId) { + OrderModel order = findById(orderId); + if (!order.userId().equals(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ณธ์ธ์˜ ์ฃผ๋ฌธ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + return order; + } + + @Transactional(readOnly = true) + public OrderModel getOrderForAdmin(Long orderId) { + return findById(orderId); + } + + @Transactional(readOnly = true) + public List getOrdersByUser(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public Page getAllForAdmin(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + private OrderModel findById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..0ffc53f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + CONFIRMED, + SHIPPING, + DELIVERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index d26bd62a7..121376a04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -13,6 +13,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Money { + public static final Money ZERO = new Money(0); + @Column(name = "price", nullable = false) private int value; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 4e455250b..84a5167c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -60,4 +60,14 @@ public Long brandId() { public int likeCount() { return likeCount; } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index fb66f8a7b..a9085b013 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -21,4 +21,6 @@ public interface ProductRepository { Page findAllByBrandId(Long brandId, Pageable pageable); List findAllByBrandId(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 1bfe9a97c..009f83cc2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -13,6 +13,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -89,6 +92,20 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(ProductModel::delete); } + @Transactional(readOnly = true) + public Map getProductsByIds(List productIds) { + return productRepository.findAllByIdInAndDeletedAtIsNull(productIds) + .stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + } + + @Transactional(readOnly = true) + public Map getBrandNamesByIds(List brandIds) { + return brandRepository.findAllByIdIn(brandIds) + .stream() + .collect(Collectors.toMap(BrandModel::getId, brand -> brand.name().value())); + } + public String getBrandName(Long brandId) { return brandRepository.findById(brandId) .map(brand -> brand.name().value()) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index 968a9be83..78e333f4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.stock; +import java.util.List; import java.util.Optional; public interface StockRepository { @@ -7,4 +8,6 @@ public interface StockRepository { StockModel save(StockModel stock); Optional findByProductId(Long productId); + + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java index 28b7a5e02..05cdbcd4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java @@ -6,6 +6,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class StockService { @@ -23,4 +27,11 @@ public StockModel getByProductId(Long productId) { return stockRepository.findByProductId(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์žฌ๊ณ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + @Transactional(readOnly = true) + public Map getByProductIds(List productIds) { + return stockRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 87c8e5dc9..35ba50009 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -3,9 +3,12 @@ import com.loopers.domain.brand.BrandModel; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface BrandJpaRepository extends JpaRepository { Optional findByNameValue(String value); + + List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 6f7a3684d..26ead19f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -34,4 +35,9 @@ public Optional findByName(String name) { public Page findAll(Pageable pageable) { return brandJpaRepository.findAll(pageable); } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..c091c68d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a39ac4aca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemModel save(OrderItemModel orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..e989b8197 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..d6bb42ca1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt + ) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 75c34e5d8..c0b66f92a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -16,4 +16,6 @@ public interface ProductJpaRepository extends JpaRepository Page findAllByBrandId(Long brandId, Pageable pageable); List findAllByBrandId(Long brandId); + + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 2b63af7da..eab1cd675 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -50,4 +50,9 @@ public Page findAllByBrandId(Long brandId, Pageable pageable) { public List findAllByBrandId(Long brandId) { return productJpaRepository.findAllByBrandId(brandId); } + + @Override + public List findAllByIdInAndDeletedAtIsNull(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java index bd61eabae..b378a8822 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -3,9 +3,12 @@ import com.loopers.domain.stock.StockModel; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface StockJpaRepository extends JpaRepository { Optional findByProductId(Long productId); + + List findAllByProductIdIn(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index f2d91d987..e3335aac9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -22,4 +23,9 @@ public StockModel save(StockModel stock) { public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); } + + @Override + public List findAllByProductIdIn(List productIds) { + return stockJpaRepository.findAllByProductIdIn(productIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..b42d0c491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,13 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("ํ•„์ˆ˜ ์š”์ฒญ ํ—ค๋” '%s'๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..8cc7b9b25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.member.MemberAuthService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + private final OrderService orderService; + private final MemberAuthService memberAuthService; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + @RequestBody OrderV1Dto.CreateRequest request, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + OrderResult result = orderFacade.placeOrder(member.getId(), request.toCommands()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(result)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse> getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + ZonedDateTime start = startAt.atStartOfDay(ZoneId.of("Asia/Seoul")); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.of("Asia/Seoul")); + + List orders = orderService.getOrdersByUser(member.getId(), start, end); + List response = orders.stream() + .map(OrderV1Dto.OrderSummaryResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + @PathVariable Long orderId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberModel member = memberAuthService.authenticate(loginId, password); + OrderModel order = orderService.getOrder(orderId, member.getId()); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(order, items)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..548109abf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest(List items) { + + public List toCommands() { + return items.stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + } + } + + public record OrderItemRequest(Long productId, int quantity) { + } + + public record OrderResponse( + Long orderId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderResult result) { + List items = result.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + result.order().getId(), + result.order().status().name(), + result.order().totalAmount().value(), + items, + result.order().getCreatedAt() + ); + } + + public static OrderResponse from(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java new file mode 100644 index 000000000..a174c61e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderService orderService; + + @GetMapping + public ApiResponse> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orders = orderService.getAllForAdmin(PageRequest.of(page, size)); + Page response = orders.map( + OrderAdminV1Dto.OrderSummaryResponse::from + ); + return ApiResponse.success(response); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderModel order = orderService.getOrderForAdmin(orderId); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(order, items)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java new file mode 100644 index 000000000..d5cf035a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java @@ -0,0 +1,72 @@ +package com.loopers.interfaces.api.order.admin; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderAdminV1Dto { + + public record OrderResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + List items, + ZonedDateTime createdAt + ) { + + public static OrderResponse from(OrderModel order, List items) { + List itemResponses = items.stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + itemResponses, + order.getCreatedAt() + ); + } + } + + public record OrderSummaryResponse( + Long orderId, + Long userId, + String status, + int totalAmount, + ZonedDateTime createdAt + ) { + + public static OrderSummaryResponse from(OrderModel order) { + return new OrderSummaryResponse( + order.getId(), + order.userId(), + order.status().name(), + order.totalAmount().value(), + order.getCreatedAt() + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + int quantity, + int subtotal + ) { + + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.productId(), + item.productName(), + item.productPrice().value(), + item.quantity(), + item.subtotal().value() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..a79842ecf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -0,0 +1,174 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.stock.StockService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class OrderFacadeIntegrationTest { + + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductService productService; + @Autowired private BrandService brandService; + @Autowired private StockService stockService; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { return brandService.register(name, "์„ค๋ช…").getId(); } + private Long createProduct(String name, int price, Long brandId, int stock) { + return productService.register(name, "์„ค๋ช…", new Money(price), brandId, stock).getId(); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class PlaceOrder { + + @DisplayName("๋‹จ์ผ ์ƒํ’ˆ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void placesSingleItemOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + assertAll( + () -> assertThat(result.order().userId()).isEqualTo(1L), + () -> assertThat(result.order().status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(258000)), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(result.items().get(0).quantity()).isEqualTo(2) + ); + } + + @DisplayName("๋ณต์ˆ˜ ์ƒํ’ˆ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void placesMultiItemOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId, 5); + OrderResult result = orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 2), new OrderItemCommand(p2, 1))); + assertAll( + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(417000)), + () -> assertThat(result.items()).hasSize(2) + ); + } + + @DisplayName("์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ๋‹ค") + @Test + void deductsStock() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3))); + assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ ์‹œ BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenInsufficientStock() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 2); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 5)))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋‘ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์žฌ๊ณ ๋„ ๋กค๋ฐฑ๋œ๋‹ค") + @Test + void rollsBackOnPartialFailure() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId, 1); + assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( + new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)))); + assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductDeleted() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + productService.delete(productId); + CoreException result = assertThrows(CoreException.class, + () -> orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("์Šค๋ƒ…์ƒท์œผ๋กœ ์ฃผ๋ฌธ ๋‹น์‹œ ์ƒํ’ˆ ์ •๋ณด๊ฐ€ ์ €์žฅ๋œ๋‹ค") + @Test + void savesProductSnapshot() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + OrderResult result = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderItemModel item = result.items().get(0); + assertAll( + () -> assertThat(item.productName()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(item.productPrice()).isEqualTo(new Money(129000)) + ); + } + } + + @DisplayName("์ฃผ๋ฌธ ์กฐํšŒ") + @Nested + class GetOrder { + + @DisplayName("๋ณธ์ธ์˜ ์ฃผ๋ฌธ์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void getsOwnOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + OrderModel order = orderService.getOrder(r.order().getId(), 1L); + assertThat(order.getId()).isEqualTo(r.order().getId()); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ์„ ์กฐํšŒํ•˜๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenAccessingOtherUserOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1))); + CoreException result = assertThrows(CoreException.class, + () -> orderService.getOrder(r.order().getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void getsOrderItems() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + OrderResult r = orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 2))); + List items = orderService.getOrderItems(r.order().getId()); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).productName()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(items.get(0).quantity()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..d98918bd1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemModelTest { + + @DisplayName("์ฃผ๋ฌธ ์ƒํ’ˆ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + Long orderId = 1L; + Long productId = 2L; + String productName = "์—์–ด๋งฅ์Šค 90"; + Money productPrice = new Money(129000); + int quantity = 2; + + // when + OrderItemModel item = new OrderItemModel(orderId, productId, productName, productPrice, quantity); + + // then + assertAll( + () -> assertThat(item.orderId()).isEqualTo(orderId), + () -> assertThat(item.productId()).isEqualTo(productId), + () -> assertThat(item.productName()).isEqualTo(productName), + () -> assertThat(item.productPrice()).isEqualTo(productPrice), + () -> assertThat(item.quantity()).isEqualTo(quantity) + ); + } + + @DisplayName("orderId๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenOrderIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(null, 1L, "์ƒํ’ˆ", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, null, "์ƒํ’ˆ", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName์ด null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductNameNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, null, new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName์ด ๊ณต๋ฐฑ์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductNameBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, " ", new Money(1000), 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productPrice๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenProductPriceNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "์ƒํ’ˆ", null, 1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity๊ฐ€ 0์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenQuantityZero() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItemModel(1L, 1L, "์ƒํ’ˆ", new Money(1000), 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์†Œ๊ณ„ ๊ณ„์‚ฐ") + @Nested + class Subtotal { + + @DisplayName("๊ฐ€๊ฒฉ * ์ˆ˜๋Ÿ‰์œผ๋กœ ์†Œ๊ณ„๋ฅผ ๊ณ„์‚ฐํ•œ๋‹ค") + @Test + void calculatesSubtotal() { + OrderItemModel item = new OrderItemModel(1L, 1L, "์—์–ด๋งฅ์Šค", new Money(129000), 2); + assertThat(item.subtotal()).isEqualTo(new Money(258000)); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด ๊ฐ€๊ฒฉ๊ณผ ์†Œ๊ณ„๊ฐ€ ๊ฐ™๋‹ค") + @Test + void subtotalEqualsUnitPriceWhenQuantityIsOne() { + Money price = new Money(59000); + OrderItemModel item = new OrderItemModel(1L, 1L, "์–‘๋ง", price, 1); + assertThat(item.subtotal()).isEqualTo(price); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..d0c5ad35d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderModelTest { + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + @Test + void createsWithValidInput() { + // given + Long userId = 1L; + Money totalAmount = new Money(258000); + + // when + OrderModel order = new OrderModel(userId, totalAmount); + + // then + assertAll( + () -> assertThat(order.userId()).isEqualTo(userId), + () -> assertThat(order.status()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.totalAmount()).isEqualTo(totalAmount) + ); + } + + @DisplayName("์ƒ์„ฑ ์‹œ ์ƒํƒœ๋Š” CREATED์ด๋‹ค") + @Test + void defaultStatusIsCreated() { + // given & when + OrderModel order = new OrderModel(1L, new Money(10000)); + + // then + assertThat(order.status()).isEqualTo(OrderStatus.CREATED); + } + + @DisplayName("userId๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenUserIdNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(null, new Money(10000))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsWhenTotalAmountNull() { + // given & when + CoreException result = assertThrows(CoreException.class, + () -> new OrderModel(1L, null)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 938f3b1bf..81ca675e8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -74,7 +74,7 @@ void updatesNameDescriptionPrice() { } } - @DisplayName("likeCount ์ดˆ๊ธฐ๊ฐ’") + @DisplayName("likeCount") @Nested class LikeCount { @@ -87,5 +87,46 @@ void defaultsToZero() { // then assertThat(product.likeCount()).isEqualTo(0); } + + @DisplayName("incrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด likeCount๊ฐ€ 1 ์ฆ๊ฐ€ํ•œ๋‹ค") + @Test + void incrementsLikeCount() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + + // when + product.incrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด likeCount๊ฐ€ 1 ๊ฐ์†Œํ•œ๋‹ค") + @Test + void decrementsLikeCount() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + product.incrementLikeCount(); + product.incrementLikeCount(); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(1); + } + + @DisplayName("likeCount๊ฐ€ 0์ผ ๋•Œ decrementLikeCount()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด 0์„ ์œ ์ง€ํ•œ๋‹ค") + @Test + void doesNotGoBelowZero() { + // given + ProductModel product = new ProductModel("์—์–ด๋งฅ์Šค 90", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + + // when + product.decrementLikeCount(); + + // then + assertThat(product.likeCount()).isEqualTo(0); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index da4d8d6f1..91d249996 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -19,6 +19,7 @@ import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -27,6 +28,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class ProductServiceTest { @@ -314,6 +316,83 @@ void softDeletesSuccessfully() { } } + @DisplayName("์ƒํ’ˆ ๋ฐฐ์น˜ ์กฐํšŒ") + @Nested + class GetProductsByIds { + + @DisplayName("ID ๋ชฉ๋ก์œผ๋กœ ๋ฏธ์‚ญ์ œ ์ƒํ’ˆ์„ Map์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMapOfProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + ProductModel product2 = new ProductModel("์กฐ๋˜", "๋†๊ตฌํ™”", new Money(159000), 1L); + ReflectionTestUtils.setField(product2, "id", 2L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1, product2)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).name()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(result.get(2L).name()).isEqualTo("์กฐ๋˜") + ); + } + + @DisplayName("์‚ญ์ œ๋œ ์ƒํ’ˆ์€ ์ œ์™ธ๋œ๋‹ค") + @Test + void excludesDeletedProducts() { + // given + List productIds = List.of(1L, 2L); + ProductModel product1 = new ProductModel("์—์–ด๋งฅ์Šค", "๋Ÿฌ๋‹ํ™”", new Money(129000), 1L); + ReflectionTestUtils.setField(product1, "id", 1L); + when(productRepository.findAllByIdInAndDeletedAtIsNull(productIds)) + .thenReturn(List.of(product1)); + + // when + Map result = productService.getProductsByIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result).containsKey(1L), + () -> assertThat(result).doesNotContainKey(2L) + ); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ๋ช… ๋ฐฐ์น˜ ์กฐํšŒ") + @Nested + class GetBrandNamesByIds { + + @DisplayName("๋ธŒ๋žœ๋“œ ID ๋ชฉ๋ก์œผ๋กœ ๋ธŒ๋žœ๋“œ๋ช… Map์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMapOfBrandNames() { + // given + List brandIds = List.of(1L, 2L); + BrandModel brand1 = new BrandModel(new BrandName("๋‚˜์ดํ‚ค"), "์Šคํฌ์ธ "); + ReflectionTestUtils.setField(brand1, "id", 1L); + BrandModel brand2 = new BrandModel(new BrandName("์•„๋””๋‹ค์Šค"), "์Šคํฌ์ธ "); + ReflectionTestUtils.setField(brand2, "id", 2L); + when(brandRepository.findAllByIdIn(brandIds)) + .thenReturn(List.of(brand1, brand2)); + + // when + Map result = productService.getBrandNamesByIds(brandIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L)).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.get(2L)).isEqualTo("์•„๋””๋‹ค์Šค") + ); + } + } + @DisplayName("๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์ „์ฒด ์‚ญ์ œ") @Nested class DeleteAllByBrandId { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java index b5e0d13c7..b320819d7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockServiceTest.java @@ -10,6 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -94,4 +96,30 @@ void throwsWhenNotFound() { assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } + + @DisplayName("์ƒํ’ˆ๋ณ„ ์žฌ๊ณ  ๋ฐฐ์น˜ ์กฐํšŒ") + @Nested + class GetByProductIds { + + @DisplayName("productId ๋ชฉ๋ก์œผ๋กœ ์žฌ๊ณ  Map์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMapOfStocks() { + // given + List productIds = List.of(1L, 2L); + StockModel stock1 = new StockModel(1L, 100); + StockModel stock2 = new StockModel(2L, 50); + when(stockRepository.findAllByProductIdIn(productIds)) + .thenReturn(List.of(stock1, stock2)); + + // when + Map result = stockService.getByProductIds(productIds); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(1L).quantity()).isEqualTo(100), + () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..ad16e4213 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -0,0 +1,195 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final MemberJpaRepository memberJpaRepository; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp, + MemberJpaRepository memberJpaRepository) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.memberJpaRepository = memberJpaRepository; + } + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } + + private Long createBrand(String name) { + var req = new BrandAdminV1Dto.CreateRequest(name, "์„ค๋ช…"); + return testRestTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private Long createProduct(String name, int price, Long brandId, int stock) { + var req = new ProductAdminV1Dto.CreateRequest(name, "์„ค๋ช…", price, brandId, stock); + return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}).getBody().data().id(); + } + + private void signupMember() { + var req = new MemberV1Dto.SignupRequest("testuser", "Test1234!", "ํ…Œ์ŠคํŠธ์œ ์ €", + LocalDate.of(1998, 1, 1), "test@example.com"); + testRestTemplate.exchange("/api/v1/members", HttpMethod.POST, new HttpEntity<>(req), + new ParameterizedTypeReference>() {}); + } + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.set("X-Loopers-LoginId", "testuser"); + h.set("X-Loopers-LoginPw", "Test1234!"); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private ResponseEntity> placeOrder(List items) { + return testRestTemplate.exchange("/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(new OrderV1Dto.CreateRequest(items), authHeaders()), + new ParameterizedTypeReference<>() {}); + } + + @DisplayName("POST /api/v1/orders") @Nested + class CreateOrder { + @DisplayName("๋‹จ์ผ ์ƒํ’ˆ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") @Test + void createsSingleItemOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + signupMember(); + var response = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().status()).isEqualTo("CREATED"), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @DisplayName("๋ณต์ˆ˜ ์ƒํ’ˆ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") @Test + void createsMultiItemOrder() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long p1 = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + Long p2 = createProduct("์กฐ๋˜", 159000, brandId, 5); + signupMember(); + var response = placeOrder(List.of( + new OrderV1Dto.OrderItemRequest(p1, 2), new OrderV1Dto.OrderItemRequest(p2, 1))); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(417000), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ ์‹œ 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test + void returns400WhenInsufficientStock() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 1); + signupMember(); + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 5))), authHeaders()), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("์ธ์ฆ ํ—ค๋” ๋ˆ„๋ฝ ์‹œ 400์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") @Test + void returns400WhenNoAuth() { + ResponseEntity> response = testRestTemplate.exchange("/api/v1/orders", + HttpMethod.POST, new HttpEntity<>(new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(1L, 1)))), + new ParameterizedTypeReference<>() {}); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/orders") @Nested + class GetMyOrders { + @DisplayName("๊ธฐ๊ฐ„๋ณ„ ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") @Test + void returnsOrdersByDateRange() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + String today = LocalDate.now().toString(); + var response = testRestTemplate.exchange( + "/api/v1/orders?startAt=" + today + "&endAt=" + today, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") @Nested + class GetOrder { + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ธ๋ฅผ ์กฐํšŒํ•œ๋‹ค") @Test + void returnsOrderDetail() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 2))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().totalAmount()).isEqualTo(258000), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/orders") @Nested + class AdminGetOrders { + @DisplayName("๊ด€๋ฆฌ์ž ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") @Test + void returnsAllOrders() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + signupMember(); + placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + var response = testRestTemplate.exchange("/api-admin/v1/orders?page=0&size=20", + HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") @Nested + class AdminGetOrder { + @DisplayName("๊ด€๋ฆฌ์ž ์ฃผ๋ฌธ ์ƒ์„ธ๋ฅผ ์กฐํšŒํ•œ๋‹ค") @Test + void returnsOrderDetailForAdmin() { + Long brandId = createBrand("๋‚˜์ดํ‚ค"); + Long productId = createProduct("์—์–ด๋งฅ์Šค", 129000, brandId, 10); + signupMember(); + var orderResponse = placeOrder(List.of(new OrderV1Dto.OrderItemRequest(productId, 1))); + Long orderId = orderResponse.getBody().data().orderId(); + var response = testRestTemplate.exchange("/api-admin/v1/orders/" + orderId, + HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md new file mode 100644 index 000000000..9fe9a26fa --- /dev/null +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -0,0 +1,337 @@ +# DDD ์„ค๊ณ„ ํ”„๋ ˆ์ž„์›Œํฌ + +> ์ด ํ”„๋กœ์ ํŠธ์˜ ๋„๋ฉ”์ธ ์„ค๊ณ„ ์‹œ ์ฐธ์กฐํ•˜๋Š” ์‚ฌ๊ณ  ํ”„๋ ˆ์ž„์›Œํฌ. +> ์‹ค์ œ ์„ค๊ณ„ ๊ณผ์ •์—์„œ ํ๋ฆ„์„ ๊ฒ€ํ† ํ•˜๊ณ , DDD ์ •์„๊ณผ ๋Œ€์กฐํ•˜์—ฌ ์กฐ์ •ํ•œ ๊ฒฐ๊ณผ๋ฌผ์ด๋‹ค. + +--- + +## 1. DDD ์„ค๊ณ„ ํ๋ฆ„ (์ˆ˜์ • ์ „ vs ์ˆ˜์ • ํ›„) + +### ์›๋ž˜ ํ๋ฆ„ + +``` +1. ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ ๊ตฌ๋ถ„ +2. ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด ๊ตฌ๋ถ„ +3. ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ๊ตฌ๋ถ„ +4. ๋ฃจํŠธ ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ +5. ๋ ˆ์ด์–ด ๊ตฌ๋ถ„ +6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค / ๋น„์ฆˆ๋‹ˆ์Šค ์„œ๋น„์Šค +``` + +### ์กฐ์ •๋œ ํ๋ฆ„ (DDD ์ •์„) + +``` +1. ์„œ๋ธŒ๋„๋ฉ”์ธ ์‹๋ณ„ + ๋ถ„๋ฅ˜ (Core / Supporting / Generic) +2. ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด โ†” ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ (๋™์‹œ ๋ฐœ๊ฒฌ) +3. ์ปจํ…์ŠคํŠธ ๋งคํ•‘ (๊ด€๊ณ„ ์ •์˜) +4. ์ „์ˆ ์  ์„ค๊ณ„ (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ + ์—”ํ‹ฐํ‹ฐ/VO + ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ) +5. ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ (๊ตฌํ˜„) +6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค vs ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค (์šฉ์–ด ์ •์ •) +``` + +### ๋Œ€์กฐํ‘œ + +``` +์ˆ˜์ • ์ „ ์ˆ˜์ • ํ›„ +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +1. ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ ๊ตฌ๋ถ„ โ†’ 1. ์„œ๋ธŒ๋„๋ฉ”์ธ ์‹๋ณ„ + ๋ถ„๋ฅ˜ (Core/Supporting/Generic) +2. ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด ๊ตฌ๋ถ„ โ” 2. ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด โ†” ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ (๋™์‹œ ๋ฐœ๊ฒฌ) +3. ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ๊ตฌ๋ถ„ โ”˜ + (๋น ์ง) โ†’ 3. ์ปจํ…์ŠคํŠธ ๋งคํ•‘ (๊ด€๊ณ„ ์ •์˜) +4. ๋ฃจํŠธ ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ โ†’ 4. ์ „์ˆ ์  ์„ค๊ณ„ (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ + ์—”ํ‹ฐํ‹ฐ/VO + ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ) +5. ๋ ˆ์ด์–ด ๊ตฌ๋ถ„ โ†’ 5. ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ (๊ตฌํ˜„) +6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค/๋น„์ฆˆ๋‹ˆ์Šค ์„œ๋น„์Šค โ†’ 6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค vs ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค (์šฉ์–ด ์ •์ •) +``` + +**3๊ฐ€์ง€ ํ•ต์‹ฌ ์กฐ์ • ์‚ฌํ•ญ:** +1. **2๋ฒˆ๊ณผ 3๋ฒˆ์€ ๋ถ„๋ฆฌ๋œ ๋‹จ๊ณ„๊ฐ€ ์•„๋‹ˆ๋ผ ํ•˜๋‚˜์˜ ๋™์‹œ ๊ณผ์ •** โ€” ์–ธ์–ด ์ฐจ์ด๋ฅผ ๋ฐœ๊ฒฌํ•˜๋Š” ๊ฒƒ์ด ๊ณง ๊ฒฝ๊ณ„๋ฅผ ๊ธ‹๋Š” ๊ฒƒ +2. **์ปจํ…์ŠคํŠธ ๋งคํ•‘์ด ๋ˆ„๋ฝ** โ€” ๋‚˜๋ˆˆ ์ปจํ…์ŠคํŠธ๋“ค์˜ ํ†ต์‹  ๋ฐฉ์‹์„ ์ •์˜ํ•ด์•ผ ํ•จ +3. **"๋น„์ฆˆ๋‹ˆ์Šค ์„œ๋น„์Šค"๋Š” DDD ์šฉ์–ด๊ฐ€ ์•„๋‹˜** โ€” "์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค"๋กœ ์ •์ • + +--- + +## 2. ๊ฐ ๋‹จ๊ณ„ ์ƒ์„ธ + +### 2-1. ์„œ๋ธŒ๋„๋ฉ”์ธ ์‹๋ณ„ + ๋ถ„๋ฅ˜ + +> "์ด ์‚ฌ์—…์—์„œ ๋ญ˜ ํ•˜๋Š”๊ฐ€"๋ฅผ ์˜์—ญ๋ณ„๋กœ ์ชผ๊ฐœ๊ณ , ์–ด๋””์— ์„ค๊ณ„ ์—ญ๋Ÿ‰์„ ์ง‘์ค‘ํ• ์ง€ ๊ฒฐ์ •ํ•œ๋‹ค. + +"์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ"์ด๋ผ๋Š” ํ‘œํ˜„์€ ๋ฐฉํ–ฅ์€ ๋งž์ง€๋งŒ, DDD์—์„œ๋Š” **์„œ๋ธŒ๋„๋ฉ”์ธ(Subdomain)**์ด๋ผ๋Š” ๋” ๊ตฌ์ฒด์ ์ธ ๋ถ„๋ฅ˜ ๊ธฐ์ค€์„ ์‚ฌ์šฉํ•œ๋‹ค. + +**์„œ๋ธŒ๋„๋ฉ”์ธ์˜ 3๊ฐ€์ง€ ์œ ํ˜•:** + +| ์œ ํ˜• | ์˜๋ฏธ | ์„ค๊ณ„ ์ „๋žต | +|------|------|-----------| +| **Core** | ๋น„์ฆˆ๋‹ˆ์Šค ๊ฒฝ์Ÿ๋ ฅ์˜ ํ•ต์‹ฌ | ์ง์ ‘ ์„ค๊ณ„ํ•˜๊ณ  ์ •๊ตํ•˜๊ฒŒ ๊ตฌํ˜„ | +| **Supporting** | Core๋ฅผ ๋ณด์กฐ. ์ค‘์š”ํ•˜์ง€๋งŒ ์ฐจ๋ณ„ํ™” ์š”์†Œ๋Š” ์•„๋‹˜ | ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋˜ Core๋งŒํผ์˜ ํˆฌ์ž๋Š” ๋ถˆํ•„์š” | +| **Generic** | ์–ด๋””์„œ๋‚˜ ๋น„์Šทํ•˜๊ฒŒ ํ•„์š”ํ•œ ๋ฒ”์šฉ ๊ธฐ๋Šฅ | ์™ธ๋ถ€ ์†”๋ฃจ์…˜ ์‚ฌ์šฉ ๊ฐ€๋Šฅ | + +์ด ๋ถ„๋ฅ˜๊ฐ€ ์™œ ํ•„์š”ํ•œ๊ฐ€: Core ์„œ๋ธŒ๋„๋ฉ”์ธ์€ ์ง์ ‘ ์ •๊ตํ•˜๊ฒŒ ์„ค๊ณ„ํ•˜๊ณ , Generic์€ ์™ธ๋ถ€ ์†”๋ฃจ์…˜์„ ์“ธ ์ˆ˜ ์žˆ๋‹ค. +"์–ด๋””์— ์‹œ๊ฐ„์„ ์“ธ ๊ฒƒ์ธ๊ฐ€"์˜ ํŒ๋‹จ ๊ทผ๊ฑฐ๊ฐ€ ๋œ๋‹ค. + +### 2-2. ์œ ๋น„์ฟผํ„ฐ์Šค ์–ธ์–ด โ†” ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ (๋™์‹œ ๋ฐœ๊ฒฌ) + +> "์ƒํ’ˆ์ด ๋ญ”๋ฐ?"๋ผ๊ณ  ๋ฌผ์—ˆ์„ ๋•Œ ๋Œ€๋‹ต์ด ๋‹ฌ๋ผ์ง€๋Š” ์ง€์ ์ด ๊ณง ๊ฒฝ๊ณ„๋‹ค. +> ์–ธ์–ด ์ฐจ์ด๋ฅผ ๋ฐœ๊ฒฌํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฒฝ๊ณ„๋ฅผ ๊ธ‹๋Š” ๊ฒƒ์€ ๊ฐ™์€ ํ–‰์œ„์˜ ์–‘๋ฉด์ด๋‹ค. + +**๋น„์œ :** ์ง€๋„์—์„œ ๊ตญ๊ฒฝ์„ ์„ ๊ธ‹๋Š” ๊ฒƒ๊ณผ ๊ฐ ๋‚˜๋ผ์˜ ๊ณต์šฉ์–ด๋ฅผ ์ •ํ•˜๋Š” ๊ฒƒ. "์—ฌ๊ธฐ์„œ๋ถ€ํ„ฐ ์ด ์–ธ์–ด๊ฐ€ ํ†ตํ•˜์ง€ ์•Š๋Š”๋‹ค"๋ฅผ ๋ฐœ๊ฒฌํ•˜๋Š” ์ˆœ๊ฐ„์ด ๊ณง ๊ตญ๊ฒฝ์„ ์ด ๊ทธ์–ด์ง€๋Š” ์ˆœ๊ฐ„์ด๋‹ค. ์–ธ์–ด๋ฅผ ๋จผ์ € ์ •ํ•˜๊ณ  ๊ตญ๊ฒฝ์„ ๊ทธ๋ฆฌ๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, **์–ธ์–ด ์ฐจ์ด๊ฐ€ ๊ตญ๊ฒฝ์„ ๋“œ๋Ÿฌ๋‚ธ๋‹ค.** + +**ํŒ๋ณ„ ๊ธฐ์ค€:** "๊ฐ™์€ ๋‹จ์–ด๊ฐ€ ๋‹ค๋ฅธ ์†์„ฑ/ํ–‰์œ„๋ฅผ ์š”๊ตฌํ•˜๋Š” ์ง€์  = ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ๊ฒฝ๊ณ„" + +**์ ์šฉ ๋ฒ”์œ„ ํ•œ์ •:** ์ด ๊ธฐ์ค€์€ **๋™์ผ ๋„๋ฉ”์ธ ์šฉ์–ด๊ฐ€ ์—ฌ๋Ÿฌ ๋งฅ๋ฝ์—์„œ ์‚ฌ์šฉ๋  ๋•Œ๋งŒ** ์ ์šฉ๋œ๋‹ค. ์• ์ดˆ์— ๋‹ค๋ฅธ ๋‹จ์–ด๋ฅผ ์“ฐ๋Š” ์˜์—ญ(์˜ˆ: "์ƒํ’ˆ"๊ณผ "๊ฒฐ์ œ์ˆ˜๋‹จ")์€ ๋น„์ฆˆ๋‹ˆ์Šค ๊ด€์‹ฌ์‚ฌ ์ž์ฒด๊ฐ€ ๋‹ค๋ฅด๋ฏ€๋กœ ๋ณ„๋„ ์ปจํ…์ŠคํŠธ๋‹ค. + +### 2-3. ์ปจํ…์ŠคํŠธ ๋งคํ•‘ + +> ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ๋ฅผ ๋‚˜๋ˆด์œผ๋ฉด, "์–˜๋„ค๊ฐ€ ์„œ๋กœ ์–ด๋–ป๊ฒŒ ๋Œ€ํ™”ํ•˜๋Š”๊ฐ€"๋ฅผ ์ •ํ•ด์•ผ ํ•œ๋‹ค. + +ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์—์„œ **Facade๊ฐ€ ์ด ์—ญํ• ์„ ์ˆ˜ํ–‰**ํ•˜๊ณ  ์žˆ๋‹ค. ๋ชจ๋†€๋ฆฌ์Šค์—์„œ๋Š” ์ง์ ‘ ํ˜ธ์ถœ์ด ์‹ค์šฉ์ ์ด์ง€๋งŒ, ์‹œ์Šคํ…œ์ด ์ปค์กŒ์„ ๋•Œ **์–ด๋””์„œ ์ž˜๋ผ์•ผ ํ•˜๋Š”๊ฐ€**๋ฅผ ๋ฏธ๋ฆฌ ์ธ์‹ํ•˜๋Š” ๋‹จ๊ณ„๋‹ค. + +### 2-4. ์ „์ˆ ์  ์„ค๊ณ„ + +> ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ์•ˆ์—์„œ ๊ฒฐ์ •ํ•˜๋Š” ๊ฒƒ๋“ค. + +| ๊ฒฐ์ • ์‚ฌํ•ญ | ์งˆ๋ฌธ | ํ”„๋กœ์ ํŠธ ์˜ˆ์‹œ | +|-----------|------|--------------| +| **์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ ๊ฒฝ๊ณ„** | ์–ด๋””๊นŒ์ง€๊ฐ€ ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜ ๋‹จ์œ„์ธ๊ฐ€? | Product์™€ Stock์€ ๋ณ„๋„ ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ | +| **์—”ํ‹ฐํ‹ฐ vs ๊ฐ’ ๊ฐ์ฒด** | ์ด ๊ฐ์ฒด์— ๊ณ ์œ  ์‹๋ณ„์ž๊ฐ€ ํ•„์š”ํ•œ๊ฐ€? | `Money`๋Š” VO, `ProductModel`์€ ์—”ํ‹ฐํ‹ฐ | +| **๋„๋ฉ”์ธ ์ด๋ฒคํŠธ** | ์ปจํ…์ŠคํŠธ ๊ฐ„ ํ†ต์‹ ์€ ์–ด๋–ป๊ฒŒ? | ์ข‹์•„์š” ์ƒ์„ฑ โ†’ likeCount ๊ฐฑ์‹  | + +### 2-5. ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ (๊ตฌํ˜„) + +์ „๋žต์ /์ „์ˆ ์  ์„ค๊ณ„๊ฐ€ ๋๋‚œ ํ›„ ์ฝ”๋“œ๋กœ ์˜ฎ๊ธฐ๋Š” ๋‹จ๊ณ„. + +``` +interfaces/ โ†’ ์™ธ๋ถ€ ์š”์ฒญ ์ˆ˜์‹  (Controller, DTO) +application/ โ†’ ์œ ์Šค์ผ€์ด์Šค ์กฐ์œจ (Facade) +domain/ โ†’ ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ (Entity, VO, DomainService) +infrastructure/ โ†’ ๊ธฐ์ˆ  ๊ตฌํ˜„ (JpaRepository) +``` + +### 2-6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค vs ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค + +> "๋น„์ฆˆ๋‹ˆ์Šค ์„œ๋น„์Šค"๋Š” DDD ์šฉ์–ด๊ฐ€ ์•„๋‹ˆ๋‹ค. ๊ตฌ๋ถ„ํ•˜๋ ค๋Š” ๊ฒƒ์€ ์•„๋ž˜ ๋‘ ๊ฐ€์ง€๋‹ค. + +| | ๋„๋ฉ”์ธ ์„œ๋น„์Šค | ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค | +|---|---|---| +| **ํ˜„์žฌ ํ”„๋กœ์ ํŠธ** | `domain/XxxService` | `application/XxxFacade` | +| **๋‹ด๋Š” ๊ฒƒ** | ๋น„์ฆˆ๋‹ˆ์Šค **๊ทœ์น™** | ์œ ์Šค์ผ€์ด์Šค **์ ˆ์ฐจ** | +| **ํŒ๋ณ„ ์งˆ๋ฌธ** | "์ด ๋กœ์ง์ด ํŠน์ • ์—”ํ‹ฐํ‹ฐ ํ•˜๋‚˜์˜ ์ฑ…์ž„์ธ๊ฐ€?" โ†’ No โ†’ ๋„๋ฉ”์ธ ์„œ๋น„์Šค | "์ด๊ฒƒ์€ ๊ทœ์น™์ธ๊ฐ€, ์ ˆ์ฐจ์ธ๊ฐ€?" โ†’ ์ ˆ์ฐจ โ†’ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค | + +**์ ์šฉ ๋ฒ”์œ„:** ์ด "๊ทœ์น™ vs ์ ˆ์ฐจ" ๊ตฌ๋ถ„์€ **๋„๋ฉ”์ธ ๊ณ„์ธต๊ณผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ณ„์ธต ์‚ฌ์ด**์—์„œ๋งŒ ์œ ํšจํ•˜๋‹ค. Controller(HTTP ๋ณ€ํ™˜)๋‚˜ Repository(๋ฐ์ดํ„ฐ ์ ‘๊ทผ)์—๋Š” ์ ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + +--- + +## 3. ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ ์„œ๋ธŒ๋„๋ฉ”์ธ ๋ถ„๋ฅ˜ํ‘œ + +| ์„œ๋ธŒ๋„๋ฉ”์ธ | ์œ ํ˜• | ๊ทผ๊ฑฐ | +|-----------|------|------| +| **์นดํƒˆ๋กœ๊ทธ** (์ƒํ’ˆ + ๋ธŒ๋žœ๋“œ) | Core | ๊ณ ๊ฐ์—๊ฒŒ ๋ณด์—ฌ์ค„ ์ƒํ’ˆ์„ ๊ด€๋ฆฌ. ๋น„์ฆˆ๋‹ˆ์Šค ์ „์‹œ์˜ ํ•ต์‹ฌ | +| **์ฃผ๋ฌธ** | Core | ๊ฑฐ๋ž˜๋ฅผ ๊ธฐ๋กํ•˜๊ณ  ๊ด€๋ฆฌ. ๋งค์ถœ์˜ ์ง์ ‘์  ๊ทผ๊ฐ„ | +| **์žฌ๊ณ ** | Supporting | ์ฃผ๋ฌธ๊ณผ ์นดํƒˆ๋กœ๊ทธ๋ฅผ ๋ณด์กฐ. ์ค‘์š”ํ•˜์ง€๋งŒ ๋…์ž์  ๊ฒฝ์Ÿ๋ ฅ์€ ์•„๋‹˜ | +| **์ข‹์•„์š”** | Supporting | ๊ณ ๊ฐ ์„ ํ˜ธ ์ถ”์ . ์นดํƒˆ๋กœ๊ทธ ์ •๋ ฌ(์ธ๊ธฐ์ˆœ)์— ํ™œ์šฉ | +| **ํšŒ์›/์ธ์ฆ** | Generic | ์–ด๋””์„œ๋‚˜ ๋น„์Šทํ•œ ๋ฒ”์šฉ ๊ธฐ๋Šฅ. ์™ธ๋ถ€ ์†”๋ฃจ์…˜ ๋Œ€์ฒด ๊ฐ€๋Šฅ | + +**Member๊ฐ€ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ๊ณผ ์™„์ „ํžˆ ๋…๋ฆฝ์ ์ธ ์ด์œ :** +ํšŒ์› ์ธ์ฆ์€ Generic ์„œ๋ธŒ๋„๋ฉ”์ธ์ด๋‹ค. ๋‹ค๋ฅธ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ๊ฒฐํ•ฉ๋  ์ด์œ ๊ฐ€ ์—†์œผ๋ฉฐ, ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์—์„œ๋„ `userId`๋งŒ์œผ๋กœ ์ฐธ์กฐํ•˜๊ณ  ์žˆ๋‹ค. + +--- + +## 4. ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ๋ฐœ๊ฒฌ ๊ณผ์ • + +### "์ƒํ’ˆ์ด ๋ญ”๋ฐ?" โ€” ๊ฐ™์€ ๋‹จ์–ด, ๋‹ค๋ฅธ ์˜๋ฏธ + +ํ˜„์žฌ `ProductModel`์— ๋ชจ๋“  ๊ด€์‹ฌ์‚ฌ๊ฐ€ ํ•œ ์—”ํ‹ฐํ‹ฐ์— ๋ชจ์—ฌ์žˆ๋‹ค: + +```java +public class ProductModel extends BaseEntity { + private String name; // โ† "์ƒํ’ˆ์„ ์ „์‹œํ•œ๋‹ค" ๊ด€์  + private String description; // โ† "์ƒํ’ˆ์„ ์ „์‹œํ•œ๋‹ค" ๊ด€์  + private Money price; // โ† "์ƒํ’ˆ์˜ ๊ฐ€์น˜๋ฅผ ๋งค๊ธด๋‹ค" ๊ด€์  + private Long brandId; // โ† "์ƒํ’ˆ์ด ์–ด๋–ค ๋ธŒ๋žœ๋“œ์ธ์ง€" ๊ด€์  + private int likeCount; // โ† "์ƒํ’ˆ์ด ์–ผ๋งˆ๋‚˜ ์ธ๊ธฐ์žˆ๋Š”์ง€" ๊ด€์  +} +``` + +"์ƒํ’ˆ์ด ๋ญ”๋ฐ?"๋ผ๊ณ  ๋ฌผ์œผ๋ฉด ๋งฅ๋ฝ๋งˆ๋‹ค ๋‹ต์ด ๋‹ค๋ฅด๋‹ค: + +| ๋งฅ๋ฝ | "์ƒํ’ˆ"์˜ ์˜๋ฏธ | ๊ด€์‹ฌ ์žˆ๋Š” ์†์„ฑ | ๊ด€์‹ฌ ์—†๋Š” ์†์„ฑ | +|------|-------------|---------------|---------------| +| **์นดํƒˆ๋กœ๊ทธ** | ๊ณ ๊ฐ์—๊ฒŒ ๋ณด์—ฌ์ค„ ์ „์‹œ๋ฌผ | name, description, price, brand | quantity, likeCount | +| **์žฌ๊ณ ** | ์ฐฝ๊ณ ์—์„œ ๊ด€๋ฆฌํ•  ๋ฌผ๊ฑด | productId, quantity, status | name, description, brand | +| **์ข‹์•„์š”** | ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํ˜ธ๋ฅผ ํ‘œํ˜„ํ•œ ๋Œ€์ƒ | productId (์ฐธ์กฐ๋งŒ) | name, price, quantity | +| **์ฃผ๋ฌธ** | ๊ฑฐ๋ž˜์˜ ๋Œ€์ƒ (๊ฐ€๊ฒฉ์ด ํ™•์ •๋œ ์‹œ์ ) | productId, ์ฃผ๋ฌธ์‹œ์ ๊ฐ€๊ฒฉ, ์ˆ˜๋Ÿ‰ | ํ˜„์žฌ๊ฐ€๊ฒฉ, ์žฌ๊ณ , ์ข‹์•„์š” | + +๊ฐ™์€ "์ƒํ’ˆ"์ธ๋ฐ **ํ•„์š”ํ•œ ์†์„ฑ์ด ์™„์ „ํžˆ ๋‹ค๋ฅด๋‹ค.** ์ด ์ฐจ์ด๊ฐ€ ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ์˜ ๊ฒฝ๊ณ„๋‹ค. + +### ๋ฌด์˜์‹์ ์œผ๋กœ ์ด๋ฏธ ์ ์šฉํ•˜๊ณ  ์žˆ๋Š” ๊ฒฝ๊ณ„ + +- `LikeModel`์ด `ProductModel`์„ ์ง์ ‘ ์ฐธ์กฐํ•˜์ง€ ์•Š๊ณ  `productId`๋งŒ ๋ณด์œ  +- `StockModel`๋„ `productId`๋งŒ ๋ณด์œ  +- `OrderItemModel`์— ์ฃผ๋ฌธ ์‹œ์ ์˜ `productName`, `productPrice`๋ฅผ **์Šค๋ƒ…์ƒท**์œผ๋กœ ๋ณต์‚ฌ + +์ด๊ฒƒ์ด ๋ฐ”๋กœ ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ ๊ฐ„์˜ **๋А์Šจํ•œ ์ฐธ์กฐ(ID ์ฐธ์กฐ)**์ด๋‹ค. + +### ํ”„๋กœ์ ํŠธ์˜ ๋ฐ”์šด๋””๋“œ ์ปจํ…์ŠคํŠธ + +```mermaid +graph LR + subgraph "์นดํƒˆ๋กœ๊ทธ BC" + Brand["Brand (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ)"] + Product["Product (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ)"] + end + subgraph "์žฌ๊ณ  BC" + Stock["Stock (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ)"] + end + subgraph "์ข‹์•„์š” BC" + Like["Like (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ)"] + end + subgraph "์ฃผ๋ฌธ BC" + Order["Order (์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ)"] + OrderItem["OrderItem (์—”ํ‹ฐํ‹ฐ)"] + end + subgraph "ํšŒ์›/์ธ์ฆ BC" + Member["Member"] + end + + Product -- "brandId (ID ์ฐธ์กฐ)" --> Brand + Stock -- "productId (ID ์ฐธ์กฐ)" --> Product + Like -- "productId (ID ์ฐธ์กฐ)" --> Product + Like -- "userId (ID ์ฐธ์กฐ)" --> Member + Order -- "userId (ID ์ฐธ์กฐ)" --> Member + OrderItem -- "productId + ์Šค๋ƒ…์ƒท" --> Product +``` + +**๋ธŒ๋žœ๋“œ์™€ ์ƒํ’ˆ์ด ๊ฐ™์€ BC์ธ ๊ทผ๊ฑฐ:** +๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์ „์ฒด๋ฅผ ์—ฐ์‡„ soft deleteํ•˜๋Š” ๊ฒƒ์ด **ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜**์œผ๋กœ ์ฒ˜๋ฆฌ๋œ๋‹ค(Q1). ์ด ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„๊ฐ€ ๊ฐ™์€ BC์— ์†ํ•ด์•ผ ํ•˜๋Š” ์ง์ ‘์ ์ธ ์ด์œ ๋‹ค. + +--- + +## 5. ์ปจํ…์ŠคํŠธ ๋งคํ•‘ + +### ์˜์กด ๋ฐฉํ–ฅ + +```mermaid +graph TD + Auth["ํšŒ์›/์ธ์ฆ BC
(Generic)"] + Catalog["์นดํƒˆ๋กœ๊ทธ BC
(Core)"] + Inventory["์žฌ๊ณ  BC
(Supporting)"] + LikeCtx["์ข‹์•„์š” BC
(Supporting)"] + OrderCtx["์ฃผ๋ฌธ BC
(Core)"] + + LikeCtx -- "userId" --> Auth + OrderCtx -- "userId" --> Auth + Catalog -- "brandId โ†’ Product" --> Catalog + Inventory -- "productId" --> Catalog + LikeCtx -- "productId" --> Catalog + OrderCtx -- "productId + ์Šค๋ƒ…์ƒท" --> Catalog + OrderCtx -- "์žฌ๊ณ  ์ฐจ๊ฐ" --> Inventory + LikeCtx -. "likeCount ๊ฐฑ์‹ " .-> Catalog +``` + +### ํ†ต์‹  ๋ฐฉ์‹ (ํ˜„์žฌ ๋ชจ๋†€๋ฆฌ์Šค) + +| ํ˜ธ์ถœ์ž | ํ”ผํ˜ธ์ถœ์ž | ๋ฐฉ์‹ | ์˜ˆ์‹œ | ๋ถ„๋ฆฌ ์‹œ ์ „ํ™˜ | +|--------|---------|------|------|-------------| +| `ProductFacade` | `BrandService` | ์ง์ ‘ ํ˜ธ์ถœ | ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ ํ›„ ์ƒํ’ˆ ์ƒ์„ฑ | ๊ฐ™์€ BC โ€” ๋ถ„๋ฆฌ ๋ถˆํ•„์š” | +| `ProductFacade` | `StockService` | ์ง์ ‘ ํ˜ธ์ถœ | ์ƒํ’ˆ + ์žฌ๊ณ  ๋™์‹œ ์ƒ์„ฑ | API ํ˜ธ์ถœ ๋˜๋Š” ์ด๋ฒคํŠธ | +| `LikeFacade` | `ProductService` | ์ง์ ‘ ํ˜ธ์ถœ | ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ฒดํฌ + likeCount ๊ฐฑ์‹  | **๋„๋ฉ”์ธ ์ด๋ฒคํŠธ** | +| `OrderFacade` | `ProductService` + `StockService` | ์ง์ ‘ ํ˜ธ์ถœ | ์ƒํ’ˆ ํ™•์ธ โ†’ ์žฌ๊ณ  ์ฐจ๊ฐ โ†’ ์ฃผ๋ฌธ ์ƒ์„ฑ | Saga ํŒจํ„ด | + +### ์„ค๊ณ„์  ์ฃผ์˜ ์ง€์ : `product.incrementLikeCount()` + +```java +// LikeFacade โ€” ์ข‹์•„์š” ์ปจํ…์ŠคํŠธ๊ฐ€ ์นดํƒˆ๋กœ๊ทธ ์ปจํ…์ŠคํŠธ๋ฅผ ์ง์ ‘ ์ˆ˜์ • +public void like(Long userId, Long productId) { + ProductModel product = productService.getProduct(productId); // ์นดํƒˆ๋กœ๊ทธ์—์„œ ๊ฒ€์ฆ + // ... ์ข‹์•„์š” ๋กœ์ง + product.incrementLikeCount(); // โ† ์ข‹์•„์š” BC๊ฐ€ ์นดํƒˆ๋กœ๊ทธ BC์˜ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ง์ ‘ ๋ณ€๊ฒฝ +} +``` + +๋ชจ๋†€๋ฆฌ์Šค์—์„œ๋Š” ์‹ค์šฉ์ ์ด์ง€๋งŒ, ๋ฌผ๋ฆฌ์  ๋ถ„๋ฆฌ ์‹œ **๋„๋ฉ”์ธ ์ด๋ฒคํŠธ**๋กœ ์ „ํ™˜ํ•ด์•ผ ํ•œ๋‹ค: + +```java +// ๋ถ„๋ฆฌ ์‹œ: ์ข‹์•„์š” โ†’ ์ด๋ฒคํŠธ ๋ฐœํ–‰ โ†’ ์นดํƒˆ๋กœ๊ทธ๊ฐ€ ์ˆ˜์‹ ํ•˜์—ฌ likeCount ๊ฐฑ์‹  +// ํ˜„์žฌ ๋ชจ๋†€๋ฆฌ์Šค์—์„œ๋Š” Facade์—์„œ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์ด ์‹ค์šฉ์  +``` + +--- + +## 6. ๋„๋ฉ”์ธ ์„œ๋น„์Šค vs ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค ํŒ๋ณ„ ๊ธฐ์ค€ + +### ํŒ๋ณ„ ํ๋ฆ„ + +```mermaid +flowchart TD + A["๋กœ์ง์ด ์žˆ๋‹ค"] --> B{"ํŠน์ • ์—”ํ‹ฐํ‹ฐ ํ•˜๋‚˜์˜ ์ฑ…์ž„์ธ๊ฐ€?"} + B -- "Yes" --> C["์—”ํ‹ฐํ‹ฐ ๋ฉ”์„œ๋“œ
์˜ˆ: product.incrementLikeCount()"] + B -- "No" --> D{"๊ทœ์น™์ธ๊ฐ€, ์ ˆ์ฐจ์ธ๊ฐ€?"} + D -- "๊ทœ์น™" --> E["๋„๋ฉ”์ธ ์„œ๋น„์Šค
domain/XxxService"] + D -- "์ ˆ์ฐจ" --> F["์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค
application/XxxFacade"] + + style C fill:#e8f5e9 + style E fill:#e3f2fd + style F fill:#fff3e0 +``` + +### ์ฝ”๋“œ๋กœ ๋ณด๋Š” ๊ตฌ๋ถ„ + +**๋„๋ฉ”์ธ ์„œ๋น„์Šค โ€” ๊ทœ์น™์„ ๋‹ด๋Š”๋‹ค:** + +```java +// BrandService: "๊ฐ™์€ ์ด๋ฆ„์˜ ๋ธŒ๋žœ๋“œ๋Š” ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค" +public BrandModel register(String name, String description) { + brandRepository.findByName(name).ifPresent(existing -> { + throw new CoreException(ErrorType.CONFLICT); // โ† ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + }); + return brandRepository.save(new BrandModel(name, description)); +} +``` + +**์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค(Facade) โ€” ์ ˆ์ฐจ๋ฅผ ์กฐ์œจํ•œ๋‹ค:** + +```java +// ProductFacade: "์ƒํ’ˆ ๋“ฑ๋ก ์‹œ ๋ธŒ๋žœ๋“œ ํ™•์ธ โ†’ ์ƒํ’ˆ ์ƒ์„ฑ โ†’ ์žฌ๊ณ  ์ƒ์„ฑ" +public ProductModel register(..., Long brandId, int initialStock) { + brandService.getBrand(brandId); // 1. ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ (์œ„์ž„) + ProductModel product = productService.register(...); // 2. ์ƒํ’ˆ ์ƒ์„ฑ (์œ„์ž„) + stockService.create(product.getId(), initialStock); // 3. ์žฌ๊ณ  ์ƒ์„ฑ (์œ„์ž„) + return product; // โ† ์ž์ฒด ๊ทœ์น™ ์—†์Œ, ์ ˆ์ฐจ๋งŒ ์žˆ์Œ +} +``` + +### ํŒ๋ณ„ ๊ธฐ์ค€ ์š”์•ฝํ‘œ + +| ์งˆ๋ฌธ | ๋„๋ฉ”์ธ ์„œ๋น„์Šค | ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค | +|------|-------------|-------------------| +| ์ž์ฒด ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์ด ์žˆ๋Š”๊ฐ€? | **์žˆ๋‹ค** (์œ ๋‹ˆํฌ ๊ฒ€์ฆ, ์ƒํƒœ ์ „์ด) | **์—†๋‹ค** (์œ„์ž„๋งŒ ์ˆ˜ํ–‰) | +| ๋‹ค๋ฅธ Service๋ฅผ ์กฐํ•ฉํ•˜๋Š”๊ฐ€? | ๊ฐ™์€ ๋„๋ฉ”์ธ ๋‚ด ๊ฐ์ฒด๋งŒ | **์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ Service๋ฅผ ์กฐํ•ฉ** | +| `@Transactional` ๊ฒฝ๊ณ„์ธ๊ฐ€? | ์•„๋‹ ์ˆ˜ ์žˆ์Œ | **๋งž๋‹ค** (์œ ์Šค์ผ€์ด์Šค ๋‹จ์œ„) | +| ์ œ๊ฑฐํ•˜๋ฉด ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์ด ๊นจ์ง€๋Š”๊ฐ€? | **๊นจ์ง„๋‹ค** | ์ ˆ์ฐจ๊ฐ€ ์‚ฌ๋ผ์งˆ ๋ฟ, ๊ทœ์น™์€ ์œ ์ง€๋จ | + +### ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ ๋ฐฐ์น˜ + +| ์ปดํฌ๋„ŒํŠธ | ๊ณ„์ธต | ์—ญํ•  | +|---------|------|------| +| `BrandService` | domain | ๋ธŒ๋žœ๋“œ๋ช… ์œ ๋‹ˆํฌ ๊ฒ€์ฆ, CRUD | +| `ProductService` | domain | ์ƒํ’ˆ CRUD, likeCount ์ฆ๊ฐ | +| `StockService` | domain | ์žฌ๊ณ  ์ƒ์„ฑ, ์ฐจ๊ฐ(`checkAndDecrease`) | +| `LikeService` | domain | ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ, ์กด์žฌ ์—ฌ๋ถ€ ์กฐํšŒ | +| `BrandFacade` | application | ์‚ญ์ œ ์‹œ ์†Œ์† ์ƒํ’ˆ ์—ฐ์‡„ soft delete | +| `ProductFacade` | application | ์ƒํ’ˆ + Stock ๋™์‹œ ์ƒ์„ฑ, ๋ธŒ๋žœ๋“œ ์กด์žฌ ํ™•์ธ | +| `LikeFacade` | application | ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ฒดํฌ, likeCount ๋™๊ธฐํ™” | + +--- + +## ๋ถ€๋ก: ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ ๋ถ„๋ฆฌ ํŒ๋‹จ โ€” Product vs Stock + +`ProductModel`๊ณผ `StockModel`์€ 1:1์ด์ง€๋งŒ **๋ณ„๋„ ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ**๋‹ค. + +**๊ทผ๊ฑฐ:** ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ ์žฌ๊ณ ๋ฅผ ํ•จ๊ป˜ ์ž ๊ธ€ ํ•„์š”๊ฐ€ ์—†๊ณ , ์žฌ๊ณ ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ์ž ๊ธ€ ํ•„์š”๊ฐ€ ์—†๋‹ค. ๋…๋ฆฝ์ ์œผ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅํ•œ ๋‹จ์œ„์ด๋ฏ€๋กœ ๋ณ„๋„ ์–ด๊ทธ๋ฆฌ๊ฒŒ์ดํŠธ๊ฐ€ ๋งž๋‹ค. + +| ๋ณ€๊ฒฝ ์‹œ๋‚˜๋ฆฌ์˜ค | Product ๋ณ€๊ฒฝ? | Stock ๋ณ€๊ฒฝ? | ๊ฒฐ๋ก  | +|-------------|:----------:|:----------:|------| +| ์ƒํ’ˆ๋ช… ์ˆ˜์ • | O | X | ๋…๋ฆฝ | +| ๊ฐ€๊ฒฉ ์ˆ˜์ • | O | X | ๋…๋ฆฝ | +| ์žฌ๊ณ  ์ฐจ๊ฐ (์ฃผ๋ฌธ) | X | O | ๋…๋ฆฝ | +| ์ƒํ’ˆ ๋“ฑ๋ก (์ดˆ๊ธฐ ์žฌ๊ณ  ํฌํ•จ) | O | O | Facade์—์„œ ์กฐ์œจ | diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..a11405a78 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,28 @@ +### ์ฃผ๋ฌธ ์ƒ์„ฑ +POST {{commerce-api}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 1 } + ] +} + +### ๋‚ด ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ (๊ธฐ๊ฐ„๋ณ„) +GET {{commerce-api}}/api/v1/orders?startAt=2026-01-01&endAt=2026-12-31 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### [ADMIN] ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/orders?page=0&size=20 + +### [ADMIN] ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +GET {{commerce-api}}/api-admin/v1/orders/1