From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 1/7] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. From 8f73709503ccd35478f0218ce2e5a712e4f33db5 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:33 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md ์ถ”๊ฐ€ (ํ”„๋กœ์ ํŠธ ์ปจํ…์ŠคํŠธ ๋ฐ ๊ฐœ๋ฐœ ๊ทœ์น™) - spring-security-crypto ์˜์กด์„ฑ ์ถ”๊ฐ€ - ErrorType์— UNAUTHORIZED, USER_NOT_FOUND, PASSWORD_MISMATCH ์ถ”๊ฐ€ - MySqlTestContainersConfig์— MYSQL_ROOT_PASSWORD ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ถ”๊ฐ€ Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 132 ++++++++++++++++++ apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/support/error/ErrorType.java | 7 +- .../MySqlTestContainersConfig.java | 1 + 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a88a85fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +์ด ํŒŒ์ผ์€ Claude Code๊ฐ€ ํ”„๋กœ์ ํŠธ๋ฅผ ์ดํ•ดํ•˜๋Š” ๋ฐ ํ•„์š”ํ•œ ์ปจํ…์ŠคํŠธ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + +## ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +**ํ”„๋กœ์ ํŠธ๋ช…**: loopers-java-spring-template +**๊ทธ๋ฃน ID**: com.loopers +**๋ผ์ด์„ ์Šค**: LICENSE ํŒŒ์ผ ์ฐธ์กฐ + +์ปค๋จธ์Šค ๋„๋ฉ”์ธ์„ ์œ„ํ•œ Java/Spring Boot ๊ธฐ๋ฐ˜ ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ๋ฐฑ์—”๋“œ ํ…œํ”Œ๋ฆฟ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. + +## ๊ธฐ์ˆ  ์Šคํƒ ๋ฐ ๋ฒ„์ „ + +| ๊ธฐ์ˆ  | ๋ฒ„์ „ | +|------|------| +| Java | 21 | +| Spring Boot | 3.4.4 | +| Spring Cloud Dependencies | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| Lombok | Spring Boot BOM | +| QueryDSL | Spring Boot BOM (Jakarta) | +| SpringDoc OpenAPI | 2.7.0 | +| Micrometer | Spring Boot BOM | +| Testcontainers | Spring Boot BOM | +| JUnit 5 | Spring Boot BOM | +| Mockito | 5.14.0 | +| SpringMockK | 4.0.2 | +| Instancio JUnit | 5.0.2 | +| Slack Appender | 1.6.1 | + +## ๋ชจ๋“ˆ ๊ตฌ์กฐ + +``` +loopers-java-spring-template/ +โ”œโ”€โ”€ apps/ # ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ (BootJar) +โ”‚ โ”œโ”€โ”€ commerce-api/ # REST API ์„œ๋ฒ„ (Web, OpenAPI) +โ”‚ โ”œโ”€โ”€ commerce-streamer/ # Kafka ์ŠคํŠธ๋ฆผ ์ฒ˜๋ฆฌ ์„œ๋ฒ„ +โ”‚ โ””โ”€โ”€ commerce-batch/ # Spring Batch ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ +โ”‚ +โ”œโ”€โ”€ modules/ # ๊ณต์œ  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ชจ๋“ˆ +โ”‚ โ”œโ”€โ”€ jpa/ # JPA + QueryDSL + MySQL +โ”‚ โ”œโ”€โ”€ redis/ # Spring Data Redis +โ”‚ โ””โ”€โ”€ kafka/ # Spring Kafka +โ”‚ +โ”œโ”€โ”€ supports/ # ํšก๋‹จ ๊ด€์‹ฌ์‚ฌ ์ง€์› ๋ชจ๋“ˆ +โ”‚ โ”œโ”€โ”€ jackson/ # Jackson ์ง๋ ฌํ™” ์„ค์ • +โ”‚ โ”œโ”€โ”€ logging/ # ๋กœ๊น… + Slack Appender +โ”‚ โ””โ”€โ”€ monitoring/ # Prometheus + Micrometer +โ”‚ +โ”œโ”€โ”€ docker/ # Docker ๊ด€๋ จ ์„ค์ • +โ””โ”€โ”€ http/ # HTTP ์š”์ฒญ ํŒŒ์ผ (IntelliJ HTTP Client) +``` + +### ๋ชจ๋“ˆ ์˜์กด์„ฑ ๊ด€๊ณ„ + +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring + +## ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ + +```bash +# ์ „์ฒด ๋นŒ๋“œ +./gradlew build + +# ํŠน์ • ์•ฑ ์‹คํ–‰ +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-streamer:bootRun +./gradlew :apps:commerce-batch:bootRun + +# ํ…Œ์ŠคํŠธ ์‹คํ–‰ +./gradlew test +``` + +## ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ + +- ํ…Œ์ŠคํŠธ ์‹œ `Asia/Seoul` ํƒ€์ž„์กด ์‚ฌ์šฉ +- ํ…Œ์ŠคํŠธ ํ”„๋กœํŒŒ์ผ: `test` +- Testcontainers ์‚ฌ์šฉ (MySQL, Redis, Kafka) +- JaCoCo ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ ์ƒ์„ฑ (XML ํฌ๋งท) + +## ์ฃผ์š” ์„ค์ • + +- **๋ฒ„์ „ ๊ด€๋ฆฌ**: Git ์ปค๋ฐ‹ ํ•ด์‹œ๋ฅผ ๊ธฐ๋ณธ ๋ฒ„์ „์œผ๋กœ ์‚ฌ์šฉ +- **๋นŒ๋“œ ํƒ€์ž…**: + - `apps/*` ๋ชจ๋“ˆ: BootJar (์‹คํ–‰ ๊ฐ€๋Šฅํ•œ JAR) + - `modules/*`, `supports/*` ๋ชจ๋“ˆ: ์ผ๋ฐ˜ JAR (๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ) + +## ์ฝ”๋“œ ์Šคํƒ€์ผ + +- Lombok ์‚ฌ์šฉ +- Jackson JSR310 ๋ชจ๋“ˆ๋กœ Java Time API ์ง๋ ฌํ™” +- QueryDSL Jakarta ์ŠคํŽ™ ์‚ฌ์šฉ + + +## ๊ฐœ๋ฐœ ๊ทœ์น™ +### ์ง„ํ–‰ Workflow - ์ฆ๊ฐ• ์ฝ”๋”ฉ +- **๋Œ€์›์น™** : ๋ฐฉํ–ฅ์„ฑ ๋ฐ ์ฃผ์š” ์˜์‚ฌ ๊ฒฐ์ •์€ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ œ์•ˆ๋งŒ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ตœ์ข… ์Šน์ธ๋œ ์‚ฌํ•ญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘์—…์„ ์ˆ˜ํ–‰. +- **์ค‘๊ฐ„ ๊ฒฐ๊ณผ ๋ณด๊ณ ** : AI ๊ฐ€ ๋ฐ˜๋ณต์ ์ธ ๋™์ž‘์„ ํ•˜๊ฑฐ๋‚˜, ์š”์ฒญํ•˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„, ํ…Œ์ŠคํŠธ ์‚ญ์ œ๋ฅผ ์ž„์˜๋กœ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ๊ฐœ์ž…. +- **์„ค๊ณ„ ์ฃผ๋„๊ถŒ ์œ ์ง€** : AI ๊ฐ€ ์ž„์˜ํŒ๋‹จ์„ ํ•˜์ง€ ์•Š๊ณ , ๋ฐฉํ–ฅ์„ฑ์— ๋Œ€ํ•œ ์ œ์•ˆ ๋“ฑ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜ ๊ฐœ๋ฐœ์ž์˜ ์Šน์ธ์„ ๋ฐ›์€ ํ›„ ์ˆ˜ํ–‰. + +### ๊ฐœ๋ฐœ Workflow - TDD (Red > Green > Refactor) +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋Š” 3A ์›์น™์œผ๋กœ ์ž‘์„ฑํ•  ๊ฒƒ (Arrange - Act - Assert) +#### 1. Red Phase : ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ๋จผ์ € ์ž‘์„ฑ +- ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜๋Š” ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ž‘์„ฑ +- ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ +#### 2. Green Phase : ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผํ•˜๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- Red Phase ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํ†ต๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ์ฝ”๋“œ ์ž‘์„ฑ +- ์˜ค๋ฒ„์—”์ง€๋‹ˆ์–ด๋ง ๊ธˆ์ง€ +#### 3. Refactor Phase : ๋ถˆํ•„์š”ํ•œ ์ฝ”๋“œ ์ œ๊ฑฐ ๋ฐ ํ’ˆ์งˆ ๊ฐœ์„  +- ๋ถˆํ•„์š”ํ•œ private ํ•จ์ˆ˜ ์ง€์–‘, ๊ฐ์ฒด์ง€ํ–ฅ์  ์ฝ”๋“œ ์ž‘์„ฑ +- unused import ์ œ๊ฑฐ +- ์„ฑ๋Šฅ ์ตœ์ ํ™” +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๊ฐ€ ํ†ต๊ณผํ•ด์•ผ ํ•จ +## ์ฃผ์˜์‚ฌํ•ญ +### 1. Never Do +- ์‹ค์ œ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š” ์ฝ”๋“œ, ๋ถˆํ•„์š”ํ•œ Mock ๋ฐ์ดํ„ฐ๋ฅผ ์ด์š”ํ•œ ๊ตฌํ˜„์„ ํ•˜์ง€ ๋ง ๊ฒƒ +- null-safety ํ•˜์ง€ ์•Š๊ฒŒ ์ฝ”๋“œ ์ž‘์„ฑํ•˜์ง€ ๋ง ๊ฒƒ (Java ์˜ ๊ฒฝ์šฐ, Optional ์„ ํ™œ์šฉํ•  ๊ฒƒ) +- println ์ฝ”๋“œ ๋‚จ๊ธฐ์ง€ ๋ง ๊ฒƒ + +### 2. Recommendation +- ์‹ค์ œ API ๋ฅผ ํ˜ธ์ถœํ•ด ํ™•์ธํ•˜๋Š” E2E ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด ์„ค๊ณ„ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ๋Œ€์•ˆ ๋ฐ ์ œ์•ˆ +- ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ API ์˜ ๊ฒฝ์šฐ, `.http/**.http` ์— ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑ + +### 3. Priority +1. ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…๋งŒ ๊ณ ๋ ค +2. null-safety, thread-safety ๊ณ ๋ ค +3. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ +4. ๊ธฐ์กด ์ฝ”๋“œ ํŒจํ„ด ๋ถ„์„ ํ›„ ์ผ๊ด€์„ฑ ์œ ์ง€ \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..6acd8606 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // security + implementation("org.springframework.security:spring-security-crypto") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..d64c6b49 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 @@ -11,7 +11,12 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + + /** ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edac..0495cb5b 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -14,6 +14,7 @@ public class MySqlTestContainersConfig { .withDatabaseName("loopers") .withUsername("test") .withPassword("test") + .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306) .withCommand( "--character-set-server=utf8mb4", From 9180d46950b2933dcc413c985e87236ef32a306d Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20User=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User ์—”ํ‹ฐํ‹ฐ (ํ•„๋“œ ๊ฒ€์ฆ, BCrypt ์•”ํ˜ธํ™”, ์ด๋ฆ„ ๋งˆ์Šคํ‚น) - UserRepository ์ธํ„ฐํŽ˜์ด์Šค - UserService (ํšŒ์›๊ฐ€์ž…, ์กฐํšŒ, ์ธ์ฆ, ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ) - UserTest ๋‹จ์œ„ ํ…Œ์ŠคํŠธ 47๊ฑด Co-Authored-By: Claude Opus 4.5 --- .../java/com/loopers/domain/user/User.java | 177 +++++++++ .../loopers/domain/user/UserRepository.java | 9 + .../com/loopers/domain/user/UserService.java | 46 +++ .../com/loopers/domain/user/UserTest.java | 364 ++++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..5ea7523e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,177 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern NAME_PATTERN = Pattern.compile("^[๊ฐ€-ํžฃa-zA-Z]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*]+$"); + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("uuuuMMdd") + .withResolverStyle(ResolverStyle.STRICT); + + private static final int MAX_LOGIN_ID_BYTES = 30; + private static final int MAX_NAME_BYTES = 30; + private static final int MIN_PASSWORD_LENGTH = 8; + private static final int MAX_PASSWORD_LENGTH = 16; + private static final int BIRTH_DATE_SUBSTRING_LENGTH = 4; + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + @Column(name = "email", nullable = false) + private String email; + + protected User() {} + + public User(String loginId, String password, String name, String birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = PASSWORD_ENCODER.encode(password); + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + public boolean matchPassword(String rawPassword) { + return PASSWORD_ENCODER.matches(rawPassword, this.password); + } + + public void changePassword(String newPassword) { + if (matchPassword(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ค๋ฅด๊ฒŒ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + validatePassword(newPassword, this.birthDate); + this.password = PASSWORD_ENCODER.encode(newPassword); + } + + public String getMaskedName() { + if (name.length() <= 1) { + return name; + } + char first = name.charAt(0); + char last = name.charAt(name.length() - 1); + String middle = "*".repeat(name.length() - 2); + return first + middle + last; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (loginId.getBytes(StandardCharsets.UTF_8).length > MAX_LOGIN_ID_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!NAME_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•œ๊ธ€๊ณผ ์˜๋ฌธ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (name.getBytes(StandardCharsets.UTF_8).length > MAX_NAME_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (birthDate.length() != 8 || !birthDate.matches("\\d{8}")) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ YYYYMMDD ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + try { + LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ ์งœ์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."); + } + } + + private void validatePassword(String password, String birthDate) { + if (password == null || password.length() < MIN_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (password.length() > MAX_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 16์ž ์ดํ•˜์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž(!@#$%^&*)๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + if (containsBirthDateSubstring(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ ์ •๋ณด๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + private boolean containsBirthDateSubstring(String password, String birthDate) { + for (int i = 0; i <= birthDate.length() - BIRTH_DATE_SUBSTRING_LENGTH; i++) { + String substring = birthDate.substring(i, i + BIRTH_DATE_SUBSTRING_LENGTH); + if (password.contains(substring)) { + return true; + } + } + return false; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..15889936 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..5fb27a41 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.user; + +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 UserService { + + private final UserRepository userRepository; + + @Transactional + public User register(String loginId, String password, String name, String birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๋กœ๊ทธ์ธ ID์ž…๋‹ˆ๋‹ค."); + } + User user = new User(loginId, password, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "[loginId = " + loginId + "] ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String password) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + + if (!user.matchPassword(password)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH, "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return user; + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = authenticate(loginId, currentPassword); + user.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..42748763 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,364 @@ +package com.loopers.domain.user; + +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + @DisplayName("User๋ฅผ ์ƒ์„ฑํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ž…๋ ฅ์ด ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUser_whenValidInputIsProvided() { + // arrange + String loginId = "testuser123"; + String password = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User user = new User(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(loginId), + () -> assertThat(user.getName()).isEqualTo(name), + () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("loginId๊ฐ€ null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenLoginIdIsNullOrEmpty(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId๊ฐ€ ์˜๋ฌธ+์ˆซ์ž ์™ธ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test user", "ํ…Œ์ŠคํŠธ์œ ์ €", "test_user"}) + void throwsBadRequestException_whenLoginIdContainsInvalidChars(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId๊ฐ€ 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLoginIdExceeds30Bytes() { + // arrange + String loginId = "a".repeat(31); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด ํ•œ๊ธ€+์˜๋ฌธ ์™ธ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"ํ™๊ธธ๋™123", "ํ™๊ธธ๋™!", "ํ™ ๊ธธ๋™"}) + void throwsBadRequestException_whenNameContainsInvalidChars(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name์ด 30๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNameExceeds30Bytes() { + // arrange - ํ•œ๊ธ€ 1์ž = 3๋ฐ”์ดํŠธ, 11์ž = 33๋ฐ”์ดํŠธ + String name = "๊ฐ€".repeat(11); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenBirthDateIsNullOrEmpty(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ YYYYMMDD ํฌ๋งท์ด ์•„๋‹ˆ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"1990-01-01", "19901", "990101", "2000/01/01"}) + void throwsBadRequestException_whenBirthDateHasInvalidFormat(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ ์งœ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"19901301", "19900132", "20000230", "19000001"}) + void throwsBadRequestException_whenBirthDateIsInvalidDate(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email์ด null์ด๊ฑฐ๋‚˜ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenEmailIsNullOrEmpty(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email์ด ์œ ํšจํ•˜์ง€ ์•Š์€ ํ˜•์‹์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"test", "test@", "@example.com", "test@.com", "test@com"}) + void throwsBadRequestException_whenEmailHasInvalidFormat(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password๊ฐ€ 8์ž ๋ฏธ๋งŒ์ด๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenPasswordIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test12!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password๊ฐ€ 16์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenPasswordIsTooLong() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234567890123!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password์— ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"Test1234~", "Test1234()", "Test1234<>"}) + void throwsBadRequestException_whenPasswordContainsInvalidChars(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password์— ์ƒ๋…„์›”์ผ 4์ž๋ฆฌ ์ด์ƒ ๋ถ€๋ถ„๋ฌธ์ž์—ด์ด ํฌํ•จ๋˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"Test1990!", "Test0101!", "Test9001!"}) + void throwsBadRequestException_whenPasswordContainsBirthDateSubstring(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ,") + @Nested + class MatchPassword { + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜๋ฉด, true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTrue_whenPasswordMatches() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("Test1234!"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsFalse_whenPasswordDoesNotMatch() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("WrongPass1!"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void succeeds_whenNewPasswordIsValid() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + user.changePassword("NewPass12!"); + + // assert + assertThat(user.matchPassword("NewPass12!")).isTrue(); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNewPasswordIsSameAsCurrent() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("Test1234!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenNewPasswordViolatesRules() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("short"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์ด๋ฆ„์„ ๋งˆ์Šคํ‚นํ•  ๋•Œ,") + @Nested + class GetMaskedName { + + @DisplayName("2๊ธ€์ž ์ด์ƒ์ด๋ฉด, ์ฒซ ๊ธ€์ž์™€ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋งŒ ๋ณด์ด๊ณ  ์ค‘๊ฐ„์€ *๋กœ ๋งˆ์Šคํ‚น๋œ๋‹ค.") + @Test + void masksMiddleCharacters_whenNameHasTwoOrMoreCharacters() { + // arrange + User user = new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("ํ™*๋™"); + } + + @DisplayName("1๊ธ€์ž์ด๋ฉด, ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsAsIs_whenNameHasOneCharacter() { + // arrange + User user = new User("testuser", "Test1234!", "๊น€", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("๊น€"); + } + + @DisplayName("์˜๋ฌธ ์ด๋ฆ„๋„ ๋งˆ์Šคํ‚น๋œ๋‹ค.") + @Test + void masksEnglishName() { + // arrange + User user = new User("testuser", "Test1234!", "John", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("J**n"); + } + } +} From 5e5db91f57ea36b72ce4dbf7560db941940e92f9 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:31:58 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20User=20Infrastructure=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserJpaRepository (Spring Data JPA) - UserRepositoryImpl (Repository ๊ตฌํ˜„์ฒด) - UserServiceIntegrationTest ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ 9๊ฑด Co-Authored-By: Claude Opus 4.5 --- .../user/UserJpaRepository.java | 11 + .../user/UserRepositoryImpl.java | 30 +++ .../user/UserServiceIntegrationTest.java | 209 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..fb0e51c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..9a9ed24a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..c87e0e65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,209 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +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 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 UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…ํ•  ๋•Œ,") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User result = userService.register(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, CONFLICT ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsConflictException_whenLoginIdAlreadyExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.register(loginId, "Another12!", "๊น€์ฒ ์ˆ˜", "19950505", "another@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetUserByLoginId { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + User result = userService.getUserByLoginId(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด, USER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserByLoginId(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + } + + @DisplayName("์ธ์ฆํ•  ๋•Œ,") + @Nested + class Authenticate { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ๋กœ๊ทธ์ธ ID์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUser_whenCredentialsAreValid() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + userService.register(loginId, password, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + User result = userService.authenticate(loginId, password); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์ธ์ฆํ•˜๋ฉด, USER_NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + String password = "Test1234!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, password); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + + @DisplayName("์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด, PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsPasswordMismatchException_whenPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, "WrongPass1!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์œ ํšจํ•œ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void succeeds_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass12!"; + userService.register(loginId, currentPassword, "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + userService.changePassword(loginId, currentPassword, newPassword); + + // assert + User updatedUser = userService.authenticate(loginId, newPassword); + assertThat(updatedUser).isNotNull(); + } + + @DisplayName("์ž˜๋ชป๋œ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, PASSWORD_MISMATCH ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsPasswordMismatchException_whenCurrentPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(loginId, "WrongPass1!", "NewPass12!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } +} From 81ad178f60af5a7e67abf7bfd77adbd9ef69ec61 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:09 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20Application=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EB=B0=8F=20=ED=97=A4=EB=8D=94=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserFacade, UserInfo (Application ๊ณ„์ธต) - AuthenticatedUser, AuthenticatedUserArgumentResolver (ํ—ค๋” ์ธ์ฆ) - WebMvcConfig (ArgumentResolver ๋“ฑ๋ก) Co-Authored-By: Claude Opus 4.5 --- .../loopers/application/user/UserFacade.java | 27 ++++++++++++ .../loopers/application/user/UserInfo.java | 23 ++++++++++ .../java/com/loopers/config/WebMvcConfig.java | 21 ++++++++++ .../api/auth/AuthenticatedUser.java | 4 ++ .../AuthenticatedUserArgumentResolver.java | 42 +++++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.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/api/auth/AuthenticatedUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..56fdc56d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String password, String name, String birthDate, String email) { + User user = userService.register(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId, String password) { + User user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + userService.changePassword(loginId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..ab17729e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + String birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} 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 00000000..9fa63b86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.auth.AuthenticatedUserArgumentResolver; +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 AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticatedUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 00000000..6933472f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(String loginId, String password) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java new file mode 100644 index 00000000..a4a25634 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AuthenticatedUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String password = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginId ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginPw ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + return new AuthenticatedUser(loginId, password); + } +} From beaf1a117e5b44a9450c2b801a5e9767e6b9c724 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:20 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20User=20API=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserV1Controller (POST /users, GET /users/me, PATCH /users/me/password) - UserV1Dto (์š”์ฒญ/์‘๋‹ต DTO) - UserV1ApiSpec (OpenAPI ์ŠคํŽ™) - UserV1ApiE2ETest E2E ํ…Œ์ŠคํŠธ 12๊ฑด - user-v1.http (IntelliJ HTTP Client) Co-Authored-By: Claude Opus 4.5 --- .../interfaces/api/user/UserV1ApiSpec.java | 34 +++ .../interfaces/api/user/UserV1Controller.java | 61 ++++ .../interfaces/api/user/UserV1Dto.java | 47 +++ .../interfaces/api/UserV1ApiE2ETest.java | 289 ++++++++++++++++++ http/commerce-api/user-v1.http | 27 ++ 5 files changed, 458 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java create mode 100644 http/commerce-api/user-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..944fb294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "์‚ฌ์šฉ์ž ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + summary = "ํšŒ์›๊ฐ€์ž…", + description = "์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getMe( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser + ); + + @Operation( + summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", + description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse changePassword( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..05660b0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe(AuthenticatedUser authenticatedUser) { + UserInfo info = userFacade.getMyInfo( + authenticatedUser.loginId(), + authenticatedUser.password() + ); + return ApiResponse.success(UserV1Dto.MeResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + AuthenticatedUser authenticatedUser, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword( + authenticatedUser.loginId(), + request.currentPassword(), + request.newPassword() + ); + return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..67bc1be1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record RegisterResponse(Long userId) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse(info.id()); + } + } + + public record MeResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MeResponse from(UserInfo info) { + return new MeResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record ChangePasswordResponse(String message) { + public static ChangePasswordResponse success() { + return new ChangePasswordResponse("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..47d86b79 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,289 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Register { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 201 CREATED์™€ userId๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns201AndUserId_whenValidInfoIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isNotNull() + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” loginId๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 409 CONFLICT๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns409Conflict_whenLoginIdAlreadyExists() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Another12!", + "๊น€์ฒ ์ˆ˜", + "19950505", + "another@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("์œ ํšจํ•˜์ง€ ์•Š์€ ์ž…๋ ฅ์œผ๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenInputIsInvalid() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "test@user", // ์ž˜๋ชป๋œ loginId + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ ์ •๋ณด๋กœ ์กฐํšŒํ•˜๋ฉด, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns200AndMaskedUserInfo_whenCredentialsAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("ํ™*๋™"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("์ธ์ฆ ํ—ค๋”๊ฐ€ ๋ˆ„๋ฝ๋˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenAuthHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // ํ—ค๋” ์—†์Œ + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋กœ ์กฐํšŒํ•˜๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenUserNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ฆฌ๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/users/me/password") + @Nested + class ChangePassword { + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, 200 OK์™€ ์„ฑ๊ณต ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns200AndSuccessMessage_whenCredentialsAndPasswordAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().message()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("ํ—ค๋” ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ body currentPassword๊ฐ€ ๋‹ค๋ฅด๋ฉด, 401 UNAUTHORIZED๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns401Unauthorized_whenCurrentPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ์œ„๋ฐ˜ํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenNewPasswordIsInvalid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด, 400 BAD_REQUEST๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returns400BadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 00000000..a392a881 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,27 @@ +### ํšŒ์›๊ฐ€์ž… +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ๋‚ด ์ •๋ณด ์กฐํšŒ +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +PATCH {{commerce-api}}/api/v1/users/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass12!" +} From 61db53c8d9c6170dcdb32e55e7682281b6f0b6e1 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 6 Feb 2026 01:32:31 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20PR=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20Claude=20Code=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/commands/create-pr.md (PR ํ…œํ”Œ๋ฆฟ ๊ธฐ๋ฐ˜ ์ž๋™ ์ƒ์„ฑ ์Šคํ‚ฌ) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/create-pr.md | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .claude/commands/create-pr.md diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md new file mode 100644 index 00000000..00b1102b --- /dev/null +++ b/.claude/commands/create-pr.md @@ -0,0 +1,49 @@ +ํ˜„์žฌ ๋ธŒ๋žœ์น˜์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ๋ถ„์„ํ•˜์—ฌ `.github/pull_request_template.md` ์–‘์‹์— ๋งž๋Š” PR์„ ์ž๋™ ์ƒ์„ฑํ•œ๋‹ค. + +## ์ˆ˜ํ–‰ ์ ˆ์ฐจ + +### 1๋‹จ๊ณ„: ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ถ„์„ +์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ **๋ณ‘๋ ฌ๋กœ** ์‹คํ–‰ํ•˜์—ฌ ์ •๋ณด๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค: +- `git status` (๋ณ€๊ฒฝ๋œ ํŒŒ์ผ ๋ชฉ๋ก) +- `git log main..HEAD --oneline` (ํ˜„์žฌ ๋ธŒ๋žœ์น˜์˜ ์ปค๋ฐ‹ ๋‚ด์—ญ) +- `git diff main...HEAD --stat` (๋ณ€๊ฒฝ๋œ ํŒŒ์ผ ํ†ต๊ณ„) +- `git diff main...HEAD` (์ „์ฒด ๋ณ€๊ฒฝ ๋‚ด์šฉ) + +### 2๋‹จ๊ณ„: PR ๋ณธ๋ฌธ ์ž‘์„ฑ +`.github/pull_request_template.md` ์–‘์‹์„ ์ฝ๊ณ , ์ˆ˜์ง‘ํ•œ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์•„๋ž˜ ๊ทœ์น™์— ๋”ฐ๋ผ ๋ณธ๋ฌธ์„ ์ž‘์„ฑํ•œ๋‹ค. + +#### ๐Ÿ“Œ Summary +- **๋ฐฐ๊ฒฝ**: ์ด ๋ณ€๊ฒฝ์ด ํ•„์š”ํ•œ ์ด์œ  (๊ธฐ์กด ๋ฌธ์ œ, ์š”๊ตฌ์‚ฌํ•ญ) +- **๋ชฉํ‘œ**: ์ด๋ฒˆ PR์—์„œ ๋‹ฌ์„ฑํ•˜๋ ค๋Š” ๊ฒƒ +- **๊ฒฐ๊ณผ**: ๋ณ€๊ฒฝ ํ›„ ๋‹ฌ๋ผ์ง€๋Š” ์  + +#### ๐Ÿงญ Context & Decision +- **๋ฌธ์ œ ์ •์˜**: ํ˜„์žฌ ๋™์ž‘/์ œ์•ฝ, ๋ฌธ์ œ(๋ฆฌ์Šคํฌ), ์„ฑ๊ณต ๊ธฐ์ค€์„ ๊ตฌ์ฒด์ ์œผ๋กœ ๊ธฐ์ˆ  +- **์„ ํƒ์ง€์™€ ๊ฒฐ์ •**: ์ฝ”๋“œ์—์„œ ์‹ค์ œ ์‚ฌ์šฉ๋œ ๊ธฐ์ˆ ์  ์„ ํƒ(ํŒจํ„ด, ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ, ๊ตฌ์กฐ)๊ณผ ๊ทธ ์ด์œ ๋ฅผ ๊ธฐ์ˆ . ๋Œ€์•ˆ์ด ๋ช…ํ™•ํ•˜์ง€ ์•Š์œผ๋ฉด "๋‹จ์ผ ์ ‘๊ทผ" ์œผ๋กœ ํ‘œ๊ธฐ + +#### ๐Ÿ—๏ธ Design Overview +- **๋ณ€๊ฒฝ ๋ฒ”์œ„**: ์‹ค์ œ ๋ณ€๊ฒฝ๋œ ๋ชจ๋“ˆ/๋„๋ฉ”์ธ, ์‹ ๊ทœ ์ถ”๊ฐ€ ํŒŒ์ผ, ์ œ๊ฑฐ/๋Œ€์ฒด๋œ ํŒŒ์ผ์„ ๋‚˜์—ด +- **์ฃผ์š” ์ปดํฌ๋„ŒํŠธ ์ฑ…์ž„**: ๋ณ€๊ฒฝ๋œ ์ฃผ์š” ํด๋ž˜์Šค/ํŒŒ์ผ์˜ ์—ญํ• ์„ `ComponentName`: ์„ค๋ช… ํ˜•ํƒœ๋กœ ๊ธฐ์ˆ  + +#### ๐Ÿ” Flow Diagram +- **ํ•ต์‹ฌ API ํ๋ฆ„๋งˆ๋‹ค** Mermaid `sequenceDiagram`์„ ์ž‘์„ฑํ•œ๋‹ค +- ์ฐธ์—ฌ์ž(participant)๋Š” ์‹ค์ œ ํด๋ž˜์Šค๋ช…์„ ์‚ฌ์šฉํ•œ๋‹ค +- `autonumber`๋ฅผ ํฌํ•จํ•œ๋‹ค +- ์ •์ƒ ํ๋ฆ„๊ณผ ์˜ˆ์™ธ ํ๋ฆ„(alt/else)์„ ๋ชจ๋‘ ํฌํ•จํ•œ๋‹ค +- API๊ฐ€ ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ๊ฐ๊ฐ ๋ณ„๋„ ๋‹ค์ด์–ด๊ทธ๋žจ์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค + +### 3๋‹จ๊ณ„: PR ์ƒ์„ฑ +- ๋ธŒ๋žœ์น˜๊ฐ€ ๋ฆฌ๋ชจํŠธ์— push๋˜์ง€ ์•Š์•˜์œผ๋ฉด `git push -u origin ` ์‹คํ–‰ +- `gh pr create` ๋ช…๋ น์–ด๋กœ PR ์ƒ์„ฑ +- PR ์ œ๋ชฉ์€ 70์ž ์ด๋‚ด, ๋ณ€๊ฒฝ์˜ ํ•ต์‹ฌ์„ ์š”์•ฝ +- PR ๋ณธ๋ฌธ์€ HEREDOC์œผ๋กœ ์ „๋‹ฌ + +```bash +gh pr create --title "PR ์ œ๋ชฉ" --body "$(cat <<'EOF' +... ์ž‘์„ฑ๋œ PR ๋ณธ๋ฌธ ... +EOF +)" +``` + +### 4๋‹จ๊ณ„: ๊ฒฐ๊ณผ ๋ณด๊ณ  +- ์ƒ์„ฑ๋œ PR URL์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค