From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/29] 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 c49eec329bf144e5559564e9079f0a8a383f5f7f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 3 Feb 2026 15:44:43 +0900 Subject: [PATCH 02/29] =?UTF-8?q?chore:=20gitignore=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 5a979af6..88ce09aa 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +.claude/ + +### Documentation ### +docs/ From aa462f9348025b693e5faf922cfdd8d373499f89 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 3 Feb 2026 16:38:48 +0900 Subject: [PATCH 03/29] =?UTF-8?q?docs:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EA=B6=81=EA=B8=88=EC=A6=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 77 +++++++++++++++++++++++++++++------------------------ ToDoList.md | 32 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 ToDoList.md diff --git a/README.md b/README.md index f86e4dd8..30d2f701 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,47 @@ -# Loopers Template (Spring + Java) -Loopers ์—์„œ ์ œ๊ณตํ•˜๋Š” ์Šคํ”„๋ง ์ž๋ฐ” ํ…œํ”Œ๋ฆฟ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. - -## Getting Started -ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ์•ˆ์ •์„ฑ ๋ฐ ์œ ์ง€๋ณด์ˆ˜์„ฑ ๋“ฑ์„ ์œ„ํ•ด ์•„๋ž˜์™€ ๊ฐ™์€ ์žฅ์น˜๋ฅผ ์šด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด์— ์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ ํ†ตํ•ด ํ”„๋กœ์ ํŠธ์˜ ๊ธฐ๋ฐ˜์„ ์„ค์น˜ํ•ด์ฃผ์„ธ์š”. -### Environment -`local` ํ”„๋กœํ•„๋กœ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋„๋ก, ํ•„์š” ์ธํ”„๋ผ๋ฅผ `docker-compose` ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. -```shell -docker-compose -f ./docker/infra-compose.yml up +# Round-1 ๋ชฉํ‘œ ``` -### Monitoring -`local` ํ™˜๊ฒฝ์—์„œ ๋ชจ๋‹ˆํ„ฐ๋ง์„ ํ•  ์ˆ˜ ์žˆ๋„๋ก, `docker-compose` ๋ฅผ ํ†ตํ•ด `prometheus` ์™€ `grafana` ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. - -์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ ์ดํ›„, **http://localhost:3000** ๋กœ ์ ‘์†ํ•ด, admin/admin ๊ณ„์ •์œผ๋กœ ๋กœ๊ทธ์ธํ•˜์—ฌ ํ™•์ธํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -```shell -docker-compose -f ./docker/monitoring-compose.yml up +- ๋‚˜์˜ ์˜๋„๋ฅผ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋กœ ์ž‘์„ฑํ•œ๋‹ค. +- TDD ๋ฐฉ์‹์œผ๋กœ AI์™€ ํ•จ๊ป˜ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. +- TDD๋กœ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋จผ์ € ์ •๋ฆฌํ•˜๋Š” ์žฅ์ ์„ ๋А๊ปด๋ณธ๋‹ค. +- ์ž‘๊ฒŒ ์ชผ๊ฐœ๊ณ  ์ ์ง„์ ์œผ๋กœ ์„ค๊ณ„ํ•˜๋Š” ๊ณผ์ •์„ ๋А๊ปด๋ณธ๋‹ค. +- ๋ฆฌํŒฉํ† ๋ง์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋Š”๊ฒƒ์„ ๋А๊ปด๋ณธ๋‹ค. ``` -## About Multi-Module Project -๋ณธ ํ”„๋กœ์ ํŠธ๋Š” ๋ฉ€ํ‹ฐ ๋ชจ๋“ˆ ํ”„๋กœ์ ํŠธ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐ ๋ชจ๋“ˆ์˜ ์œ„๊ณ„ ๋ฐ ์—ญํ• ์„ ๋ถ„๋ช…ํžˆ ํ•˜๊ณ , ์•„๋ž˜์™€ ๊ฐ™์€ ๊ทœ์น™์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. +## 1. ์‹œ์ž‘์ „, ๊ถ๊ธˆ์ฆ๋“ค -- apps : ๊ฐ ๋ชจ๋“ˆ์€ ์‹คํ–‰๊ฐ€๋Šฅํ•œ **SpringBootApplication** ์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. -- modules : ํŠน์ • ๊ตฌํ˜„์ด๋‚˜ ๋„๋ฉ”์ธ์— ์˜์กด์ ์ด์ง€ ์•Š๊ณ , reusable ํ•œ configuration ์„ ์›์น™์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค. -- supports : logging, monitoring ๊ณผ ๊ฐ™์ด ๋ถ€๊ฐ€์ ์ธ ๊ธฐ๋Šฅ์„ ์ง€์›ํ•˜๋Š” add-on ๋ชจ๋“ˆ์ž…๋‹ˆ๋‹ค. +### 1. ๋ชฉํ‘œ๋ฅผ ์„ธ์šฐ๋ฉฐ +- ์ด ๊ณผ์ •์—์„œ ๋‚˜๋Š” ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๊ฐ€ ๋ฌด์—‡์ธ์ง€ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ์„๊นŒ? + - ์–ด๋А ๊ณผ์ •์—์„œ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ผ๊ณ  ๋А๊ผˆ์„๊นŒ? -``` -Root -โ”œโ”€โ”€ apps ( spring-applications ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ commerce-api -โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ commerce-batch -โ”‚ โ””โ”€โ”€ ๐Ÿ“ฆ commerce-streamer -โ”œโ”€โ”€ modules ( reusable-configurations ) -โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ jpa -โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ redis -โ”‚ โ””โ”€โ”€ ๐Ÿ“ฆ kafka -โ””โ”€โ”€ supports ( add-ons ) - โ”œโ”€โ”€ ๐Ÿ“ฆ jackson - โ”œโ”€โ”€ ๐Ÿ“ฆ monitoring - โ””โ”€โ”€ ๐Ÿ“ฆ logging -``` +### 2. ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ +- ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ? ์ด๊ฒŒ ๋ญ์ง€? ์™œ ์ด ๊ตฌ์กฐ๋กœ ๋‚˜๋ˆด์–ด์•ผ ํ–ˆ์ง€? +- ๊ฐ ๋ชจ๋“ˆ์˜ ๋„ค์ด๋ฐ์—๋Š” -api , -batch, - streamer ๋ผ๊ณ  ๋ถ™์—ฌ์ ธ ์žˆ๋Š”๋ฐ ์™œ ์ด๋ ‡๊ฒŒ ๋ถ™์ธ๊ฑฐ์ง€? + + +### 3. ๋ชจ๋“ˆ ์•„ํ‚คํ…์ฒ˜(commerce-api) +- ์™œ Controller ํŒจํ‚ค์ง€๋ช…์ด ์•„๋‹Œ, interfaces๋ผ๊ณ  ํ•˜์ง€? ์ง€๊ธˆ์€ ์ปจํŠธ๋กค๋Ÿฌ ํ•˜๋‚˜ ๋ฟ์ธ๋ฐ? ํ™•์žฅ์„ฑ์„ ์œ„ํ•ด ๋จผ์ € ์ด๋ ‡๊ฒŒ ๋„ค์ด๋ฐ ์ง“๋Š”๊ฒŒ ์œ ๋ฆฌํ•œ๊ฐ€? +- ๊ณ„์ธต๊ฐ„ DTO์˜ ๋„ค์ด๋ฐ์€ ์–ด๋–ป๊ฒŒ ์ง€์–ด์•ผํ• ๊นŒ? +- DTO๋ฅผ ๋‚ด๋ถ€ํด๋ž˜์Šค๋กœ ๊ด€๋ฆฌํ•˜๊ณ  ์žˆ๋„ค? ํŒŒ์ผ ์ˆ˜๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•จ์ธ๊ฐ€? ์™œ ์ด๋ ‡๊ฒŒ ํ•œ๊ฑฐ์ง€? ์ด๊ฒŒ ํ˜„์—… ๋ฐฉ์‹์ธ๊ฐ€? +- ์™œ ์˜จ๋ณด๋”ฉ ํ”„๋กœ์ ํŠธ์— JPA๋ฅผ ์‚ฌ์šฉํ•œ๊ฑฐ์ง€? ์‰ฝ๊ณ  ๋‹ค๋“ค ์‚ฌ์šฉํ•˜๋‹ˆ๊นŒ? + + +## 2. Round1 ๋ฌธ์„œ๋ฅผ ์ฝ์œผ๋ฉฐ, ์•Œ๊ฒŒ๋œ ๋ถ€๋ถ„ + +### 1. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๋ฐ ๋”๋ธ” +- ๋‚˜๋Š” ํ…Œ์ŠคํŠธ ๋ ˆ์ด์–ด๋ณ„ ์–ด๋–ค ๊ธฐ์ˆ ๋“ค์„ ์‚ฌ์šฉํ•ด์•ผํ•˜๋Š”์ง€ ์ž˜ ๋ชจ๋ฅธ๋‹ค. +- ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ์‹œ, ๋ ˆ์ด์–ด๋ณ„ ํ…Œ์ŠคํŠธ ๊ธฐ์ˆ ๋“ค์— ์ต์ˆ™ํ•ด์ง€๋„๋กํ•œ๋‹ค. +- Mock(๋Œ€์—ญ ๊ฐ์ฒด)๋ฅผ ์˜๋ฏธํ•œ๋‹ค. ์ด ๊ธฐ์ˆ ์„ ์ž˜ ๋ชจ๋ฅธ๋‹ค. + + +### 2. ํ…Œ์ŠคํŠธ ๋”๋ธ” +- Mock(๋Œ€์—ญ ๊ฐ์ฒด)๋ฅผ ์˜๋ฏธํ•œ๋‹ค. ์ด ๊ธฐ์ˆ ์„ ์ž˜ ๋ชจ๋ฅธ๋‹ค. +- ํ…Œ์ŠคํŠธ ๋”๋ธ” ์‚ฌ์šฉ ์›์น™ ๋ฐ ์ต์ˆ™ํ•ด์ง€๋„๋ก ์ง‘์ค‘ํ•œ๋‹ค. + +### 3. TDD ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ์˜ ํŠน์ง• +- ๊ฐ€์žฅ ๊ฑฑ์ •๋˜๋Š”๊ฑด ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ถ„๋ฆฌ์—์„œ ์–ด๋–ค ๊ธฐ์ค€์œผ๋กœ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ์ชฝ์— ๋กœ์ง์„ ๋‘˜์ง€, ์„œ๋น„์Šค์— ๋‘˜์ง€ ํŒ๋‹จํ•˜๋Š” ๊ธฐ์ค€ + +--- + +## ๐Ÿ“‹ ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ + +๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ ์ •๋ฆฌ [ToDoList.md](./ToDoList.md) diff --git a/ToDoList.md b/ToDoList.md new file mode 100644 index 00000000..f6a92673 --- /dev/null +++ b/ToDoList.md @@ -0,0 +1,32 @@ +# ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ + + +### 1. ํšŒ์›๊ฐ€์ž… +- **ํ•„์š” ์ •๋ณด : { ๋กœ๊ทธ์ธ ID, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ด๋ฆ„, ์ƒ๋…„์›”์ผ, ์ด๋ฉ”์ผ }** +- ์ด๋ฏธ ๊ฐ€์ž…๋œ ๋กœ๊ทธ์ธ ID ๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•จ +- ๊ฐ ์ •๋ณด๋Š” ํฌ๋งท์— ๋งž๋Š” ๊ฒ€์ฆ ํ•„์š” (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ƒ๋…„์›”์ผ) +- ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”ํ•ด ์ €์žฅํ•˜๋ฉฐ, ์•„๋ž˜์™€ ๊ฐ™์€ ๊ทœ์น™์„ ๋”ฐ๋ฆ„ +``` +1. 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +2. ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +``` +> ์ดํ›„, ์œ ์ € ์ •๋ณด๊ฐ€ ํ•„์š”ํ•œ ๋ชจ๋“  ์š”์ฒญ์€ ์•„๋ž˜ ํ—ค๋”๋ฅผ ํ†ตํ•ด ์š”์ฒญ +> * X-Loopers-LoginId : ๋กœ๊ทธ์ธ ID +> * X-Loopers-LoginPw : ๋น„๋ฐ€๋ฒˆํ˜ธ + + +### 2. ๋‚ด ์ •๋ณด ์กฐํšŒ +- **๋ฐ˜ํ™˜ ์ •๋ณด : { ๋กœ๊ทธ์ธ ID, ์ด๋ฆ„, ์ƒ๋…„์›”์ผ, ์ด๋ฉ”์ผ }** +- ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ +- ์ด๋ฆ„์€ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•ด ๋ฐ˜ํ™˜ + +> ๋งˆ์Šคํ‚น ๋ฌธ์ž๋Š” `*` ๋กœ ํ†ต์ผ +> + +### 3. ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • +- **ํ•„์š” ์ •๋ณด : { ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ }** +- ๋น„๋ฐ€ ๋ฒˆํ˜ธ RULE ์„ ๋”ฐ๋ฅด๋˜, ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + +> **๋น„๋ฐ€๋ฒˆํ˜ธ RULE** +> * ์˜๋ฌธ ๋Œ€/์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์‚ฌ์šฉ ๊ฐ€๋Šฅ +> * ์ƒ๋…„์›”์ผ ์‚ฌ์šฉ ๋ถˆ๊ฐ€ From d105efbe62c41daf99c792a0efb239a5e591629f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 18:30:13 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=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 --- .../java/com/loopers/domain/BirthDate.java | 32 ++++++ .../main/java/com/loopers/domain/Email.java | 36 +++++++ .../main/java/com/loopers/domain/LoginId.java | 7 ++ .../main/java/com/loopers/domain/Name.java | 7 ++ .../java/com/loopers/domain/Password.java | 20 ++++ .../java/com/loopers/domain/UserModel.java | 65 ++++++++++++ .../com/loopers/domain/UserModelTest.java | 98 +++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/Name.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java new file mode 100644 index 00000000..d51da66c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java @@ -0,0 +1,32 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class BirthDate { + private final LocalDate birthDate; + + public BirthDate(LocalDate birthDate) { + validate(birthDate); + this.birthDate = birthDate; + } + + private void validate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST,"์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค."); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST,"์ƒ๋…„์›”์ผ์€ ๊ณผ๊ฑฐ ๋‚ ์งœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public String toDateString() { + return birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + public LocalDate getDate() { + return birthDate; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java new file mode 100644 index 00000000..71ea1a7a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java @@ -0,0 +1,36 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.regex.Pattern; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Embeddable +@Getter +@EqualsAndHashCode +public class Email { + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + ); + + private String mail; + + protected Email() {} + + public Email(String mail) { + validateEmail(mail); + this.mail = mail; + } + + private void validateEmail(String mail) { + if (mail == null || mail.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (!EMAIL_PATTERN.matcher(mail).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java new file mode 100644 index 00000000..d959fead --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java @@ -0,0 +1,7 @@ +package com.loopers.domain; + +public class LoginId { + public LoginId(String testuser123) { + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java new file mode 100644 index 00000000..91bbad9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -0,0 +1,7 @@ +package com.loopers.domain; + +public class Name { + public Name(String ํ™๊ธธ๋™) { + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java new file mode 100644 index 00000000..7a23c236 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -0,0 +1,20 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public class Password { + private final String password; + + public Password(String password) { + this.password = password; + } + + public void validateNotContainBirthday(BirthDate birthDate) { + String targetDate = birthDate.toDateString(); + + if (this.password.contains(targetDate)) { + throw new CoreException(ErrorType.BAD_REQUEST,"์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java new file mode 100644 index 00000000..a4e79160 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -0,0 +1,65 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserModel extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Embedded + private Name name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + public UserModel(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + validate(loginId, password, name, birthDate, email); + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + private void validate(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + validateNotNull(loginId, "๋กœ๊ทธ์ธ ID"); + validateNotNull(password, "๋น„๋ฐ€๋ฒˆํ˜ธ"); + validateNotNull(name, "์ด๋ฆ„"); + validateNotNull(birthDate, "์ƒ๋…„์›”์ผ"); + validateNotNull(email, "์ด๋ฉ”์ผ"); + + password.validateNotContainBirthday(birthDate); + } + private void validateNotNull(Object value, String fieldName) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST,fieldName + "์€(๋Š”) ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java new file mode 100644 index 00000000..eaf9ea6f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class UserModelTest { + + private LoginId validLoginId; + private Password validPassword; + private Name validName; + private BirthDate validBirthDate; + private Email validEmail; + + @BeforeEach + void setUp() { + validLoginId = new LoginId("testuser123"); + validPassword = new Password("Test1234!@#"); + validName = new Name("ํ™๊ธธ๋™"); + validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + validEmail = new Email("test@example.com"); + } + + @DisplayName("์œ ์ € ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("๋ชจ๋“  ํ•„๋“œ๊ฐ€ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createUserModel_whenAllDataProvided() { + // act + UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(user.getPassword()).isEqualTo(validPassword), + () -> assertThat(user.getName()).isEqualTo(validName), + () -> assertThat(user.getBirthDate()).isEqualTo(validBirthDate), + () -> assertThat(user.getEmail()).isEqualTo(validEmail) + ); + } + + @DisplayName("๋กœ๊ทธ์ธ ID๊ฐ€ ๋ˆ„๋ฝ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenLoginIdIsNull() { + assertThatThrownBy(() -> new UserModel(null, validPassword, validName, validBirthDate, validEmail)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ˆ„๋ฝ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenPasswordIsNull() { + assertThatThrownBy(() -> new UserModel(validLoginId, null, validName, validBirthDate, validEmail)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด ๋ˆ„๋ฝ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenNameIsNull() { + assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, null, validBirthDate, validEmail)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด ๋ˆ„๋ฝ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenBirthDateIsNull() { + assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, null, validEmail)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฉ”์ผ์ด ๋ˆ„๋ฝ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenEmailIsNull() { + assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, validBirthDate, null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createUserModel_whenPasswordContainsBirthDate() { + // arrange + BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + Password passwordWithBirthDate = new Password("pw19900115!"); + + // expect + assertThatThrownBy(() -> new UserModel(validLoginId, passwordWithBirthDate, validName, birthDate, validEmail)) + .isInstanceOf(CoreException.class); + } + } +} From 8dd32ec67c18efe54be17b26e54b60691819b9bd Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 19:29:29 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=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 --- .../main/java/com/loopers/domain/Name.java | 19 +++++- .../java/com/loopers/domain/NameTest.java | 62 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java index 91bbad9e..69ba5bb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -1,7 +1,24 @@ package com.loopers.domain; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + public class Name { - public Name(String ํ™๊ธธ๋™) { + private final static int MIN_LENGTH = 2; + private final static int MAX_LENGTH = 10; + private final String name; + + public Name(String name) { + validate(name); + this.name = name; + } + private void validate(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if(name.length() < MIN_LENGTH || name.length() > MAX_LENGTH){ + throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฆ„ ๊ธธ์ด์ž…๋‹ˆ๋‹ค."); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java new file mode 100644 index 00000000..4fb93454 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +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; + +class NameTest { + + @DisplayName("์ด๋ฆ„ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("2์ž ์ด์ƒ 10์ž ์ดํ•˜์˜ ์ด๋ฆ„์ด ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"ํ™๊ธธ", "ํ™๊ธธ๋™", "๊ฐ€๋‚˜๋‹ค๋ผ๋งˆ๋ฐ”์‚ฌ์•„์ž์ฐจ"}) + void createName_whenValidNameProvided(String validNameValue) { + // act + Name name = new Name(validNameValue); + + // assert + assertThat(name).isNotNull(); + } + + @DisplayName("์ด๋ฆ„์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createName_whenNameIsNull() { + assertThatThrownBy(() -> new Name(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด ๋นˆ ๋ฌธ์ž์—ด์ด๊ฑฐ๋‚˜ ๊ณต๋ฐฑ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void createName_whenNameIsBlank(String blankName) { + assertThatThrownBy(() -> new Name(blankName)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด 2์ž ๋ฏธ๋งŒ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createName_whenNameIsTooShort() { + String shortName = "๊ฐ€"; + + assertThatThrownBy(() -> new Name(shortName)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด 10์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createName_whenNameIsTooLong() { + String longName = "๊ฐ€๋‚˜๋‹ค๋ผ๋งˆ๋ฐ”์‚ฌ์•„์ž์ฐจ์นด"; // 11์ž + + assertThatThrownBy(() -> new Name(longName)) + .isInstanceOf(CoreException.class);} + } +} From 1069a2d0f322836559c7d01d97167058f179a4c5 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 19:36:01 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=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 --- .../java/com/loopers/domain/BirthDate.java | 6 +- .../com/loopers/domain/BirthDateTest.java | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java index d51da66c..6f88bc17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java @@ -6,6 +6,9 @@ import java.time.format.DateTimeFormatter; public class BirthDate { + + private static final DateTimeFormatter DATE_STRING_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final LocalDate birthDate; public BirthDate(LocalDate birthDate) { @@ -23,8 +26,7 @@ private void validate(LocalDate birthDate) { } public String toDateString() { - return birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); - } + return birthDate.format(DATE_STRING_FORMATTER); } public LocalDate getDate() { return birthDate; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java new file mode 100644 index 00000000..cb3c0304 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BirthDateTest { + + @DisplayName("์ƒ๋…„์›”์ผ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("๊ณผ๊ฑฐ ํ˜น์€ ํ˜„์žฌ์˜ ๋‚ ์งœ๊ฐ€ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createBirthDate_whenValidDateProvided() { + // arrange + LocalDate validDate = LocalDate.of(1995, 5, 20); + + // act + BirthDate birthDate = new BirthDate(validDate); + + // assert + assertThat(birthDate.getDate()).isEqualTo(validDate); + } + + @DisplayName("๋‚ ์งœ๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createBirthDate_whenDateIsNull() { + assertThatThrownBy(() -> new BirthDate(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค."); + } + + @DisplayName("๋‚ ์งœ๊ฐ€ ๋ฏธ๋ž˜์˜ ๋‚ ์งœ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createBirthDate_whenDateIsInFuture() { + // arrange + LocalDate futureDate = LocalDate.now().plusDays(1); + + // act & assert + assertThatThrownBy(() -> new BirthDate(futureDate)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("๊ณผ๊ฑฐ ๋‚ ์งœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + @DisplayName("๋‚ ์งœ๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ํ•  ๋•Œ, ") + @Nested + class Conversion { + + @DisplayName("yyyyMMdd ํ˜•์‹์˜ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void toDateString_returnsFormattedString() { + // arrange + BirthDate birthDate = new BirthDate(LocalDate.of(1988, 12, 5)); + + // act + String result = birthDate.toDateString(); + + // assert + assertThat(result).isEqualTo("19881205"); + } + } +} From c8625fe788485be5359c34f57cfd5f536e0725e0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 19:43:21 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8Id=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/LoginId.java | 30 ++++++++++++- .../java/com/loopers/domain/LoginIdTest.java | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java index d959fead..d932cdfc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java @@ -1,7 +1,35 @@ package com.loopers.domain; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.regex.Pattern; + public class LoginId { - public LoginId(String testuser123) { + private static final int MIN_LENGTH = 4; + private static final int MAX_LENGTH = 12; + + private static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); + + private final String value; + + public LoginId(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” 4์ž์—์„œ 12์ž ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (!ALPHANUMERIC_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ๋ฉ๋‹ˆ๋‹ค."); + } + } + public String getValue() { + return value; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java new file mode 100644 index 00000000..36e3c898 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java @@ -0,0 +1,42 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class LoginIdTest { + + @DisplayName("๋กœ๊ทธ์ธ ID ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("4~12์ž์˜ ์˜๋ฌธ/์ˆซ์ž๊ฐ€ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"user123", "loop", "loopers2026"}) + void createLoginId_whenValidValue(String validValue) { + LoginId loginId = new LoginId(validValue); + assertThat(loginId.getValue()).isEqualTo(validValue); + } + + @DisplayName("4์ž ๋ฏธ๋งŒ์ด๊ฑฐ๋‚˜ 12์ž๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"abc", "longloginid123"}) + void createLoginId_whenLengthIsInvalid(String invalidLengthValue) { + assertThatThrownBy(() -> new LoginId(invalidLengthValue)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์˜๋ฌธ/์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"user!", "๋กœ๊ทธ์ธid", "user 12"}) + void createLoginId_whenContainsInvalidChars(String invalidCharValue) { + assertThatThrownBy(() -> new LoginId(invalidCharValue)) + .isInstanceOf(CoreException.class); + } + } +} From 57e6adab4a2b56d4c43e7894c2d1108fe17a067c Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 19:48:50 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A6=84=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=82=B9=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/Name.java | 5 ++++ .../java/com/loopers/domain/NameTest.java | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java index 69ba5bb2..69afba89 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -21,4 +21,9 @@ private void validate(String name) { throw new CoreException(ErrorType.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฆ„ ๊ธธ์ด์ž…๋‹ˆ๋‹ค."); } } + + public String getMaskedName() { + if (name == null || name.isEmpty()) return name; + return name.substring(0, name.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java index 4fb93454..eeb4796e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java @@ -8,6 +8,7 @@ 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.ValueSource; class NameTest { @@ -59,4 +60,27 @@ void createName_whenNameIsTooLong() { assertThatThrownBy(() -> new Name(longName)) .isInstanceOf(CoreException.class);} } + + @DisplayName("์ด๋ฆ„์„ ๋งˆ์Šคํ‚นํ•  ๋•Œ, ") + @Nested + class Masking { + + @DisplayName("์ด๋ฆ„์˜ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๊ฐ€ '*'๋กœ ์น˜ํ™˜๋œ๋‹ค.") + @ParameterizedTest + @CsvSource({ + "ํ™๊ธธ, ํ™*", + "ํ™๊ธธ๋™, ํ™๊ธธ*", + "๊ฐ€๋‚˜๋‹ค๋ผ๋งˆ, ๊ฐ€๋‚˜๋‹ค๋ผ*" + }) + void getMaskedName_shouldMaskLastCharacter(String original, String expected) { + // arrange + Name name = new Name(original); + + // act + String maskedName = name.getMaskedName(); + + // assert + assertThat(maskedName).isEqualTo(expected); + } + } } From 0243f121c3f08d618972576ff86a621610b48ddc Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 19:53:47 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/Email.java | 4 +- .../java/com/loopers/domain/EmailTest.java | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java index 71ea1a7a..767b156b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java @@ -11,9 +11,7 @@ @Getter @EqualsAndHashCode public class Email { - private static final Pattern EMAIL_PATTERN = Pattern.compile( - "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" - ); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); private String mail; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java new file mode 100644 index 00000000..245c1f36 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java @@ -0,0 +1,66 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EmailTest { + + @DisplayName("์ด๋ฉ”์ผ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = { + "test@example.com", + "user.name+tag@domain.co.kr", + "12345@loopers.io", + "email@sub.domain.com" + }) + void createEmail_whenValidFormat(String validEmail) { + // act + Email email = new Email(validEmail); + + // assert + assertThat(email.getMail()).isEqualTo(validEmail); + } + + @DisplayName("์ด๋ฉ”์ผ์ด null์ด๊ฑฐ๋‚˜ ๋น„์–ด์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void createEmail_whenNullOrBlank(String blankEmail) { + // null ์ผ€์ด์Šค ๋ณ„๋„ ํ…Œ์ŠคํŠธ + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + + // ๊ณต๋ฐฑ ์ผ€์ด์Šค + assertThatThrownBy(() -> new Email(blankEmail)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(strings = { + "plainaddress", // @ ์—†์Œ + "#@%^%#$@#$@#.com", // ํŠน์ˆ˜๋ฌธ์ž ๋‚จ๋ฐœ + "@domain.com", // ๋กœ์ปฌ ํŒŒํŠธ ์—†์Œ + "Joe Smith ", // ์ด๋ฆ„ ํฌํ•จ + "email.domain.com", // @ ์—†์Œ + "email@domain@domain.com", // @ ์ค‘๋ณต + "email@domain..com" // ๋„๋ฉ”์ธ ๋งˆ์นจํ‘œ ์ค‘๋ณต + }) + void createEmail_whenInvalidFormat(String invalidEmail) { + assertThatThrownBy(() -> new Email(invalidEmail)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + } +} From 2398e8175e2079b85c8ad8cc3c1980cb768dae1b Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 21:42:08 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20=ED=8C=A8=EC=8A=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=83=9D=EC=9D=BC?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/BirthDate.java | 5 +- .../java/com/loopers/domain/Password.java | 36 +++++++++-- .../java/com/loopers/domain/PasswordTest.java | 61 +++++++++++++++++++ .../com/loopers/domain/UserModelTest.java | 2 +- 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java index 6f88bc17..3fc0ad51 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java @@ -4,7 +4,9 @@ import com.loopers.support.error.ErrorType; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import lombok.EqualsAndHashCode; +@EqualsAndHashCode public class BirthDate { private static final DateTimeFormatter DATE_STRING_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @@ -26,7 +28,8 @@ private void validate(LocalDate birthDate) { } public String toDateString() { - return birthDate.format(DATE_STRING_FORMATTER); } + return birthDate.format(DATE_STRING_FORMATTER); + } public LocalDate getDate() { return birthDate; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java index 7a23c236..f65af367 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -2,19 +2,43 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.util.regex.Pattern; +import lombok.EqualsAndHashCode; +@EqualsAndHashCode public class Password { - private final String password; + // ํŠน์ˆ˜๋ฌธ์ž ๋ฒ”์œ„๋ฅผ ~!@#$%^&*()_+=- ๋กœ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); - public Password(String password) { - this.password = password; + private final String value; + + public Password(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || !PASSWORD_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์กฐํ•ฉ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } } public void validateNotContainBirthday(BirthDate birthDate) { - String targetDate = birthDate.toDateString(); + String birthDateString = birthDate.toDateString(); - if (this.password.contains(targetDate)) { - throw new CoreException(ErrorType.BAD_REQUEST,"์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + if (this.value.contains(birthDateString)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } } + + public void validateNotSameAs(Password other) { + if (this.equals(other)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + public String getValue() { + return value; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java new file mode 100644 index 00000000..50d2220e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java @@ -0,0 +1,61 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PasswordTest { + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๊ฐ€ ๋ชจ๋‘ ํฌํ•จ๋˜๋ฉด ์ •์ƒ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createPassword_whenValidFormat() { + // ๋Œ€๋ฌธ์ž(V), ์†Œ๋ฌธ์ž(alid), ์ˆซ์ž(123), ํŠน์ˆ˜๋ฌธ์ž(!@#) ๋ชจ๋‘ ํฌํ•จ + assertDoesNotThrow(() -> new Password("Valid123!@#")); + } + + @DisplayName("๊ทœ์น™์— ์–ด๊ธ‹๋‚˜๋Š” ํ˜•์‹์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void createPassword_whenInvalidFormat() { + assertThatThrownBy(() -> new Password("invalid")) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๊ฒ€์ฆํ•  ๋•Œ, ") + @Nested + class Validation { + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ(yyyyMMdd)์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void validateNotContainBirthday_fail() { + // arrange: ์ •๊ทœ์‹์„ ํ†ต๊ณผํ•˜๊ธฐ ์œ„ํ•ด ๋Œ€๋ฌธ์ž 'P' ์ถ”๊ฐ€ + Password password = new Password("Pw19900115!"); + BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + + // act & assert + assertThatThrownBy(() -> password.validateNotContainBirthday(birthDate)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ˆ˜์ •ํ•˜๋ ค๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void validateNotSameAs_fail() { + // arrange + Password currentPassword = new Password("Current123!"); + Password newPassword = new Password("Current123!"); + + // act & assert + assertThatThrownBy(() -> newPassword.validateNotSameAs(currentPassword)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index eaf9ea6f..fb0f36b4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -88,7 +88,7 @@ void createUserModel_whenEmailIsNull() { void createUserModel_whenPasswordContainsBirthDate() { // arrange BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - Password passwordWithBirthDate = new Password("pw19900115!"); + Password passwordWithBirthDate = new Password("Pw19900115!"); // expect assertThatThrownBy(() -> new UserModel(validLoginId, passwordWithBirthDate, validName, birthDate, validEmail)) From c130402d46334d61c9c5459973df13a55d2266e5 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 22:40:27 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/BirthDate.java | 6 +- .../main/java/com/loopers/domain/LoginId.java | 8 ++- .../main/java/com/loopers/domain/Name.java | 8 ++- .../java/com/loopers/domain/Password.java | 6 +- .../java/com/loopers/domain/UserModel.java | 15 ++-- .../com/loopers/domain/UserRepository.java | 9 +++ .../java/com/loopers/domain/UserService.java | 25 +++++++ .../infrastructure/UserJpaRepository.java | 10 +++ .../infrastructure/UserRepositoryImpl.java | 24 +++++++ .../domain/UserServiceIntegrationTest.java | 68 +++++++++++++++++++ 10 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java index 3fc0ad51..8dcb741c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java @@ -2,16 +2,20 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import lombok.EqualsAndHashCode; +@Embeddable @EqualsAndHashCode public class BirthDate { private static final DateTimeFormatter DATE_STRING_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - private final LocalDate birthDate; + private LocalDate birthDate; + + protected BirthDate() {} public BirthDate(LocalDate birthDate) { validate(birthDate); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java index d932cdfc..98e718ed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java @@ -2,15 +2,21 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; import java.util.regex.Pattern; +import lombok.EqualsAndHashCode; +@Embeddable +@EqualsAndHashCode public class LoginId { private static final int MIN_LENGTH = 4; private static final int MAX_LENGTH = 12; private static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); - private final String value; + private String value; + + protected LoginId() {} public LoginId(String value) { validate(value); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java index 69afba89..e7495e8c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -2,11 +2,17 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +@Embeddable +@EqualsAndHashCode public class Name { private final static int MIN_LENGTH = 2; private final static int MAX_LENGTH = 10; - private final String name; + private String name; + + protected Name() {} public Name(String name) { validate(name); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java index f65af367..25143cf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -2,16 +2,20 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; import java.util.regex.Pattern; import lombok.EqualsAndHashCode; +@Embeddable @EqualsAndHashCode public class Password { // ํŠน์ˆ˜๋ฌธ์ž ๋ฒ”์œ„๋ฅผ ~!@#$%^&*()_+=- ๋กœ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); - private final String value; + private String value; + + protected Password() {} public Password(String value) { validate(value); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index a4e79160..679704fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -2,11 +2,10 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -17,26 +16,26 @@ @Entity @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor public class UserModel extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Embedded + @AttributeOverride(name = "value", column = @Column(name = "login_id")) private LoginId loginId; @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password")) private Password password; @Embedded + @AttributeOverride(name = "name", column = @Column(name = "name")) private Name name; @Embedded + @AttributeOverride(name = "birthDate", column = @Column(name = "birth_date")) private BirthDate birthDate; @Embedded + @AttributeOverride(name = "mail", column = @Column(name = "email")) private Email email; public UserModel(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java new file mode 100644 index 00000000..64706be3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain; + +import java.util.Optional; + +public interface UserRepository { + UserModel save(UserModel userModel); + + Optional find(LoginId loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java new file mode 100644 index 00000000..6820cd89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -0,0 +1,25 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + @Transactional + public UserModel signup(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + + if(userRepository.find(loginId).isPresent()) { + throw new CoreException(ErrorType.BAD_REQUEST,"์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } + + UserModel userModel = new UserModel(loginId,password,name,birthDate,email); + + return userRepository.save(userModel); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java new file mode 100644 index 00000000..adf3d6a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.LoginId; +import com.loopers.domain.UserModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(LoginId loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java new file mode 100644 index 00000000..afecf020 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.LoginId; +import com.loopers.domain.UserModel; +import com.loopers.domain.UserRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public UserModel save(UserModel userModel) { + return userJpaRepository.save(userModel); + } + + @Override + public Optional find(LoginId loginId) { + return userJpaRepository.findByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java new file mode 100644 index 00000000..8951953b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -0,0 +1,68 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.time.LocalDate; +import org.junit.jupiter.api.AfterEach; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class UserServiceIntegrationTest { + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private LoginId validLoginId; + private Password validPassword; + private Name validName; + private BirthDate validBirthDate; + private Email validEmail; + + @BeforeEach + void setUp() { + validLoginId = new LoginId("testuser123"); + validPassword = new Password("Test1234!@#"); + validName = new Name("ํ™๊ธธ๋™"); + validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + validEmail = new Email("test@example.com"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์œ ์ €๊ฐ€ ํšŒ์›๊ฐ€์ž…ํ•  ๋•Œ") + @Nested + class SingUp{ + @DisplayName("๋กœ๊ทธ์ธ ID, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ด๋ฆ„, ์ƒ๋…„์›”์ผ, ์ด๋ฉ”์ผ์„ ์ฃผ๋ฉด, ํšŒ์›๊ฐ€์ž…์„ ํ•œ๋‹ค.") + @Test + void signup_whenAllInfoProvided() { + // act + UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(result.getPassword()).isEqualTo(validPassword), + () -> assertThat(result.getName()).isEqualTo(validName), + () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), + () -> assertThat(result.getEmail()).isEqualTo(validEmail) + ); + } + } +} From 47c6353f7303e665c246a2e45219239cda6264ed Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 23:09:47 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/ApiControllerAdvice.java | 9 + .../interfaces/api/user/UserV1ApiSpec.java | 19 + .../interfaces/api/user/UserV1Controller.java | 40 +++ .../interfaces/api/user/UserV1Dto.java | 55 +++ .../interfaces/api/UserV1ApiE2ETest.java | 340 ++++++++++++++++++ http/commerce-api/user-v1.http | 131 +++++++ 6 files changed, 594 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/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..2ec0dbbd 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,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +48,13 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldError(); + String message = fieldError != null ? fieldError.getDefaultMessage() : "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."; + 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/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..cbf0dd88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "์‚ฌ์šฉ์ž API ์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + summary = "ํšŒ์›๊ฐ€์ž…", + description = "์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse signup( + @RequestBody(description = "ํšŒ์›๊ฐ€์ž… ์š”์ฒญ ์ •๋ณด") + UserV1Dto.SignupRequest 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..8e7df7fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.*; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final UserService userService; + + @PostMapping("/signup") + @Override + public ApiResponse signup( + @Valid @RequestBody UserV1Dto.SignupRequest request + ) { + LoginId loginId = new LoginId(request.loginId()); + Password password = new Password(request.password()); + Name name = new Name(request.name()); + BirthDate birthDate = new BirthDate(LocalDate.parse(request.birthDate(), BIRTH_DATE_FORMATTER)); + Email email = new Email(request.email()); + + UserModel userModel = userService.signup(loginId, password, name, birthDate, email); + UserV1Dto.SignupResponse response = UserV1Dto.SignupResponse.from(userModel); + + return ApiResponse.success(response); + } +} 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..487a6a42 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.UserModel; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class UserV1Dto { + + public record SignupRequest( + @NotBlank(message = "๋กœ๊ทธ์ธ ID๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Size(min = 4, max = 12, message = "๋กœ๊ทธ์ธ ID๋Š” 4์ž์—์„œ 12์ž ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Pattern(regexp = "^[a-zA-Z0-9]*$", message = "๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ๋ฉ๋‹ˆ๋‹ค.") + String loginId, + + @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$", + message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์กฐํ•ฉ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ) + String password, + + @NotBlank(message = "์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Size(min = 2, max = 10, message = "์ด๋ฆ„์€ 2์ž์—์„œ 10์ž ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String name, + + @NotBlank(message = "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Pattern(regexp = "^\\d{8}$", message = "์ƒ๋…„์›”์ผ์€ yyyyMMdd ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String birthDate, + + @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Email(message = "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + String email + ) { + } + + public record SignupResponse( + Long id, + String loginId, + String name, + String birthDate, + String email + ) { + public static SignupResponse from(UserModel model) { + return new SignupResponse( + model.getId(), + model.getLoginId().getValue(), + model.getName().getMaskedName(), + model.getBirthDate().toDateString(), + model.getEmail().getMail() + ); + } + } +} 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..dad89be6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,340 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.infrastructure.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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + + 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/signup") + @Nested + class Signup { + + @DisplayName("์œ ํšจํ•œ ํšŒ์›๊ฐ€์ž… ์ •๋ณด๋ฅผ ์ฃผ๋ฉด, ํšŒ์›๊ฐ€์ž…์— ์„ฑ๊ณตํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUserInfo_whenValidSignupRequestIsProvided() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("ํ™๊ธธ*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(userJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("๋กœ๊ทธ์ธ ID๊ฐ€ 4์ž ๋ฏธ๋งŒ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenLoginIdIsTooShort() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "abc", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("๋กœ๊ทธ์ธ ID๊ฐ€ 12์ž ์ดˆ๊ณผ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenLoginIdIsTooLong() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser12345", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("๋กœ๊ทธ์ธ ID์— ํŠน์ˆ˜๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "test@user", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 8์ž ๋ฏธ๋งŒ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test12!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 16์ž ์ดˆ๊ณผ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234567890123!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test19900101!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์œผ๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "invalid-email" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ ํšŒ์›๊ฐ€์ž…ํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenLoginIdAlreadyExists() { + // arrange + UserV1Dto.SignupRequest firstRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test1@example.com" + ); + UserV1Dto.SignupRequest secondRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test5678!", + "๊น€์ฒ ์ˆ˜", + "19950505", + "test2@example.com" + ); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(firstRequest), responseType); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(secondRequest), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("์ด๋ฆ„์ด 2์ž ๋ฏธ๋งŒ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenNameIsTooShort() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("์ด๋ฆ„์ด 10์ž ์ดˆ๊ณผ์ด๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenNameIsTooLong() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™๊น€์ฒ ์ˆ˜๋ฐ•์˜ํฌ์ตœ๊ฐ•", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(userJpaRepository.count()).isEqualTo(0) + ); + } + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 00000000..58ee4cb9 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,131 @@ +### ํšŒ์›๊ฐ€์ž… - ์„ฑ๊ณต ์ผ€์ด์Šค +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋กœ๊ทธ์ธ ID 4์ž ๋ฏธ๋งŒ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "abc", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋กœ๊ทธ์ธ ID 12์ž ์ดˆ๊ณผ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser12345", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋กœ๊ทธ์ธ ID ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "test@user", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋น„๋ฐ€๋ฒˆํ˜ธ 8์ž ๋ฏธ๋งŒ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test12!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋น„๋ฐ€๋ฒˆํ˜ธ 16์ž ์ดˆ๊ณผ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234567890123!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ ํฌํ•จ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test19900101!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ์ด๋ฉ”์ผ ํ˜•์‹ ์˜ค๋ฅ˜ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900101", + "email": "invalid-email" +} + +### ํšŒ์›๊ฐ€์ž… - ์ด๋ฆ„ 2์ž ๋ฏธ๋งŒ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "ํ™", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ์ด๋ฆ„ 10์ž ์ดˆ๊ณผ (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "ํ™๊ธธ๋™๊น€์ฒ ์ˆ˜๋ฐ•์˜ํฌ์ตœ๊ฐ•", + "birthDate": "19900101", + "email": "test@example.com" +} + +### ํšŒ์›๊ฐ€์ž… - ์ค‘๋ณต ๋กœ๊ทธ์ธ ID (์‹คํŒจ) +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test5678!", + "name": "๊น€์ฒ ์ˆ˜", + "birthDate": "19950505", + "email": "test2@example.com" +} From 8620b0d0d0aeff65cb912e8a2e4d88a3d1b661d0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 5 Feb 2026 23:23:16 +0900 Subject: [PATCH 13/29] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20=EC=82=AC=ED=95=AD=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ToDoList.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ToDoList.md b/ToDoList.md index f6a92673..3aa6eddf 100644 --- a/ToDoList.md +++ b/ToDoList.md @@ -1,7 +1,9 @@ # ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ -### 1. ํšŒ์›๊ฐ€์ž… +## 1. ํšŒ์›๊ฐ€์ž… + +### ์š”๊ตฌ์‚ฌํ•ญ - **ํ•„์š” ์ •๋ณด : { ๋กœ๊ทธ์ธ ID, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ด๋ฆ„, ์ƒ๋…„์›”์ผ, ์ด๋ฉ”์ผ }** - ์ด๋ฏธ ๊ฐ€์ž…๋œ ๋กœ๊ทธ์ธ ID ๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•จ - ๊ฐ ์ •๋ณด๋Š” ํฌ๋งท์— ๋งž๋Š” ๊ฒ€์ฆ ํ•„์š” (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ƒ๋…„์›”์ผ) @@ -15,7 +17,9 @@ > * X-Loopers-LoginPw : ๋น„๋ฐ€๋ฒˆํ˜ธ -### 2. ๋‚ด ์ •๋ณด ์กฐํšŒ +--- + +## 2. ๋‚ด ์ •๋ณด ์กฐํšŒ - **๋ฐ˜ํ™˜ ์ •๋ณด : { ๋กœ๊ทธ์ธ ID, ์ด๋ฆ„, ์ƒ๋…„์›”์ผ, ์ด๋ฉ”์ผ }** - ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ - ์ด๋ฆ„์€ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•ด ๋ฐ˜ํ™˜ @@ -23,10 +27,16 @@ > ๋งˆ์Šคํ‚น ๋ฌธ์ž๋Š” `*` ๋กœ ํ†ต์ผ > -### 3. ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • +--- + +## 3. ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • - **ํ•„์š” ์ •๋ณด : { ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ }** - ๋น„๋ฐ€ ๋ฒˆํ˜ธ RULE ์„ ๋”ฐ๋ฅด๋˜, ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. > **๋น„๋ฐ€๋ฒˆํ˜ธ RULE** > * ์˜๋ฌธ ๋Œ€/์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์‚ฌ์šฉ ๊ฐ€๋Šฅ > * ์ƒ๋…„์›”์ผ ์‚ฌ์šฉ ๋ถˆ๊ฐ€ + + +--- + From 7472c4cd2e38568b1220aba2fd7e0feef860c9a7 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:26 +0900 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=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 --- .../loopers/domain/AuthenticationService.java | 27 +++++++++++++++++++ .../com/loopers/support/error/ErrorType.java | 1 + 2 files changed, 28 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java new file mode 100644 index 00000000..97734843 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java @@ -0,0 +1,27 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final UserRepository userRepository; + + public UserModel authenticate(String loginIdValue, String rawPassword) { + LoginId loginId = new LoginId(loginIdValue); + Password password = new Password(rawPassword); + + UserModel user = userRepository.find(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + if (!user.getPassword().equals(password)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + return user; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..8d493491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** ๋ฒ”์šฉ ์—๋Ÿฌ */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); From 249a25f7403c60bf2d7d3529dd69736899dfb1f3 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:32 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/UserService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index 6820cd89..72a0fd2f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -22,4 +22,9 @@ public UserModel signup(LoginId loginId, Password password, Name name, BirthDate return userRepository.save(userModel); } + + public UserModel getMyInfo(LoginId loginId) { + return userRepository.find(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } } From dc35f185e702869e0561757ecbcd0e65ecbced89 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:37 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/user/UserV1ApiSpec.java | 12 +++++++++++ .../interfaces/api/user/UserV1Controller.java | 21 +++++++++++++++---- .../interfaces/api/user/UserV1Dto.java | 16 ++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) 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 index cbf0dd88..1be37d4a 100644 --- 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 @@ -2,6 +2,7 @@ import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.tags.Tag; @@ -16,4 +17,15 @@ ApiResponse signup( @RequestBody(description = "ํšŒ์›๊ฐ€์ž… ์š”์ฒญ ์ •๋ณด") UserV1Dto.SignupRequest request ); + + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ํ—ค๋”์— X-Loopers-LoginId์™€ X-Loopers-LoginPw๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getMyInfo( + @Parameter(description = "๋กœ๊ทธ์ธ ID", required = true) + String loginId, + @Parameter(description = "๋น„๋ฐ€๋ฒˆํ˜ธ", required = true) + String password + ); } 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 index 8e7df7fc..e97600d9 100644 --- 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 @@ -4,10 +4,7 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -18,8 +15,11 @@ public class UserV1Controller implements UserV1ApiSpec { private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; private final UserService userService; + private final AuthenticationService authenticationService; @PostMapping("/signup") @Override @@ -37,4 +37,17 @@ public ApiResponse signup( return ApiResponse.success(response); } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password + ) { + UserModel authenticatedUser = authenticationService.authenticate(loginId, password); + UserModel userInfo = userService.getMyInfo(authenticatedUser.getLoginId()); + UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); + + return ApiResponse.success(response); + } } 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 index 487a6a42..29429429 100644 --- 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 @@ -52,4 +52,20 @@ public static SignupResponse from(UserModel model) { ); } } + + public record MyInfoResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MyInfoResponse from(UserModel model) { + return new MyInfoResponse( + model.getLoginId().getValue(), + model.getName().getMaskedName(), + model.getBirthDate().toDateString(), + model.getEmail().getMail() + ); + } + } } From b00608d493bee9e35457c69a9e0d366215307248 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:40 +0900 Subject: [PATCH 17/29] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/AuthenticationServiceTest.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java new file mode 100644 index 00000000..234ba177 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -0,0 +1,103 @@ +package com.loopers.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.LocalDate; +import java.util.Optional; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("AuthenticationService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private AuthenticationService authenticationService; + + private UserModel testUser; + private String validLoginId; + private String validPassword; + + @BeforeEach + void setUp() { + validLoginId = "testuser123"; + validPassword = "Test1234!@#"; + + testUser = new UserModel( + new LoginId(validLoginId), + new Password(validPassword), + new Name("ํ™๊ธธ๋™"), + new BirthDate(LocalDate.of(1990, 1, 15)), + new Email("test@example.com") + ); + } + + @DisplayName("authenticate ๋ฉ”์„œ๋“œ๋Š”") + @Nested + class Authenticate { + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ๋กœ๊ทธ์ธ ID์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void authenticate_should_return_user_when_credentials_are_correct() { + // arrange + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + + // act + UserModel result = authenticationService.authenticate(validLoginId, validPassword); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId().getValue()).isEqualTo(validLoginId); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์ธ์ฆํ•˜๋ฉด UNAUTHORIZED ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void authenticate_should_throw_exception_when_user_not_found() { + // arrange + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, validPassword)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasMessageContaining("๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆํ•˜๋ฉด UNAUTHORIZED ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void authenticate_should_throw_exception_when_password_is_incorrect() { + // arrange + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + String wrongPassword = "Wrong1234!@#"; + + // act & assert + assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, wrongPassword)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasMessageContaining("๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void authenticate_should_throw_exception_when_password_is_null() { + // act & assert + assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} From 264e51ad79d822d592e82ef8466c926f462db645 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:46 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/UserServiceIntegrationTest.java | 39 +++++++ .../interfaces/api/UserV1ApiE2ETest.java | 107 ++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index 8951953b..c8b9fc54 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -1,9 +1,12 @@ package com.loopers.domain; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import java.time.LocalDate; import org.junit.jupiter.api.AfterEach; @@ -65,4 +68,40 @@ void signup_whenAllInfoProvided() { ); } } + + @DisplayName("์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด๋ฅผ ์กฐํšŒํ•  ๋•Œ") + @Nested + class GetMyInfo { + @DisplayName("๋กœ๊ทธ์ธ ID๋กœ ๋‚ด ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค") + @Test + void getMyInfo_whenValidLoginId() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act + UserModel result = userService.getMyInfo(validLoginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(result.getName()).isEqualTo(validName), + () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), + () -> assertThat(result.getEmail()).isEqualTo(validEmail) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void getMyInfo_whenInvalidLoginId() { + // arrange + LoginId invalidLoginId = new LoginId("invalid123"); + + // act & assert + assertThatThrownBy(() -> userService.getMyInfo(invalidLoginId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasMessageContaining("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } } 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 index dad89be6..9355bbd9 100644 --- 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 @@ -337,4 +337,111 @@ void throwsBadRequest_whenNameIsTooLong() { ); } } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMyInfo { + + private static final String ENDPOINT_MY_INFO = "/api/v1/users/me"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ ํ—ค๋”๋กœ ๋‚ด ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๋ฉด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnsMyInfo_whenValidAuthenticationHeaders() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + // act + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("ํ™๊ธธ*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 401 UNAUTHORIZED ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void throwsUnauthorized_whenUserNotFound() { + // arrange + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "nonexistent"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์กฐํšŒํ•˜๋ฉด, 401 UNAUTHORIZED ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("์ธ์ฆ ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด, 4xx ์—๋Ÿฌ ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void throwsError_whenAuthenticationHeadersMissing() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, null, responseType); + + // assert + assertTrue(response.getStatusCode().is4xxClientError() || response.getStatusCode().is5xxServerError()); + } + } } From 5f11aef8896d2c6221d64ef882bac6967cbcbce2 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 00:29:49 +0900 Subject: [PATCH 19/29] =?UTF-8?q?docs:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20HTTP=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/user-v1.http | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http index 58ee4cb9..d16889d0 100644 --- a/http/commerce-api/user-v1.http +++ b/http/commerce-api/user-v1.http @@ -129,3 +129,25 @@ Content-Type: application/json "birthDate": "19950505", "email": "test2@example.com" } + +############################################### +# ๋‚ด ์ •๋ณด ์กฐํšŒ API +############################################### + +### ๋‚ด ์ •๋ณด ์กฐํšŒ - ์„ฑ๊ณต ์ผ€์ด์Šค +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### ๋‚ด ์ •๋ณด ์กฐํšŒ - ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ID (์‹คํŒจ) +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: nonexistent +X-Loopers-LoginPw: Test1234! + +### ๋‚ด ์ •๋ณด ์กฐํšŒ - ์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ (์‹คํŒจ) +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +### ๋‚ด ์ •๋ณด ์กฐํšŒ - ์ธ์ฆ ํ—ค๋” ๋ˆ„๋ฝ (์‹คํŒจ) +GET {{commerce-api}}/api/v1/users/me From 09bbbe19accd332f08253a9a5b77b3779bb9dded Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:02 +0900 Subject: [PATCH 20/29] =?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=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/main/java/com/loopers/domain/UserModel.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index 679704fd..931e95e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -61,4 +61,15 @@ private void validateNotNull(Object value, String fieldName) { throw new CoreException(ErrorType.BAD_REQUEST,fieldName + "์€(๋Š”) ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค."); } } + + public void changePassword(Password currentPassword, Password newPassword) { + if (!this.password.equals(currentPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + newPassword.validateNotSameAs(currentPassword); + newPassword.validateNotContainBirthday(this.birthDate); + + this.password = newPassword; + } } From ec973bae17485c9ba296b71c9ec1edd29205c6a8 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:02 +0900 Subject: [PATCH 21/29] =?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?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/main/java/com/loopers/domain/UserService.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index 72a0fd2f..79dda8b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -27,4 +27,13 @@ public UserModel getMyInfo(LoginId loginId) { return userRepository.find(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + @Transactional + public void changePassword(LoginId loginId, Password currentPassword, Password newPassword) { + UserModel user = userRepository.find(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + user.changePassword(currentPassword, newPassword); + userRepository.save(user); + } } From 46d8afeb8671588b9c9e7431469f6d1709af50b6 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:03 +0900 Subject: [PATCH 22/29] =?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=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../interfaces/api/user/UserV1ApiSpec.java | 13 ++++++++++++ .../interfaces/api/user/UserV1Controller.java | 17 +++++++++++++++ .../interfaces/api/user/UserV1Dto.java | 21 +++++++++++++++++++ 3 files changed, 51 insertions(+) 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 index 1be37d4a..b3d0743c 100644 --- 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 @@ -28,4 +28,17 @@ ApiResponse getMyInfo( @Parameter(description = "๋น„๋ฐ€๋ฒˆํ˜ธ", required = true) String password ); + + @Operation( + summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", + description = "์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. ํ—ค๋”์— X-Loopers-LoginId์™€ X-Loopers-LoginPw๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse changePassword( + @Parameter(description = "๋กœ๊ทธ์ธ ID", required = true) + String loginId, + @Parameter(description = "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ", required = true) + String currentPassword, + @RequestBody(description = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ ์ •๋ณด") + 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 index e97600d9..2f715515 100644 --- 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 @@ -50,4 +50,21 @@ public ApiResponse getMyInfo( return ApiResponse.success(response); } + + @PatchMapping("/password") + @Override + public ApiResponse changePassword( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String currentPasswordValue, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPasswordValue); + + Password currentPassword = new Password(request.currentPassword()); + Password newPassword = new Password(request.newPassword()); + + userService.changePassword(authenticatedUser.getLoginId(), currentPassword, 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 index 29429429..7daae480 100644 --- 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 @@ -68,4 +68,25 @@ public static MyInfoResponse from(UserModel model) { ); } } + + public record ChangePasswordRequest( + @NotBlank(message = "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + String currentPassword, + + @NotBlank(message = "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$", + message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์กฐํ•ฉ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ) + String newPassword + ) { + } + + public record ChangePasswordResponse( + String message + ) { + public static ChangePasswordResponse success() { + return new ChangePasswordResponse("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } } From ec29637752eeba409d480758365311b746704068 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:04 +0900 Subject: [PATCH 23/29] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/loopers/domain/UserModelTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index fb0f36b4..9e6fda09 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -95,4 +95,66 @@ void createUserModel_whenPasswordContainsBirthDate() { .isInstanceOf(CoreException.class); } } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ, ") + @Nested + class ChangePassword { + + private UserModel user; + + @BeforeEach + void setUp() { + user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + } + + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ฃผ๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changePassword_whenValidPasswords() { + // arrange + Password newPassword = new Password("NewPass123!@"); + + // act + user.changePassword(validPassword, newPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newPassword); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void changePassword_whenCurrentPasswordNotMatch() { + // arrange + Password wrongPassword = new Password("Wrong123!@#"); + Password newPassword = new Password("NewPass123!@"); + + // act & assert + assertThatThrownBy(() -> user.changePassword(wrongPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void changePassword_whenNewPasswordSameAsCurrent() { + // arrange + Password samePassword = new Password("Test1234!@#"); + + // act & assert + assertThatThrownBy(() -> user.changePassword(validPassword, samePassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void changePassword_whenNewPasswordContainsBirthDate() { + // arrange + Password newPasswordWithBirthDate = new Password("Pw19900115!"); + + // act & assert + assertThatThrownBy(() -> user.changePassword(validPassword, newPasswordWithBirthDate)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } } From b6350d40792e67bea2b84294cdcfcf16d61dd6dd Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:04 +0900 Subject: [PATCH 24/29] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../domain/UserServiceIntegrationTest.java | 73 ++++++ .../interfaces/api/UserV1ApiE2ETest.java | 232 ++++++++++++++++++ 2 files changed, 305 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index c8b9fc54..9b9f538d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -104,4 +104,77 @@ void getMyInfo_whenInvalidLoginId() { .hasMessageContaining("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } } + + @DisplayName("์œ ์ €๊ฐ€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ") + @Nested + class ChangePassword { + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ฃผ๋ฉด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค") + @Test + void changePassword_whenValidPasswords() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + Password newPassword = new Password("NewPass123!@"); + + // act + userService.changePassword(validLoginId, validPassword, newPassword); + + // assert + UserModel updatedUser = userService.getMyInfo(validLoginId); + assertThat(updatedUser.getPassword()).isEqualTo(newPassword); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void changePassword_whenCurrentPasswordNotMatch() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + Password wrongPassword = new Password("Wrong123!@#"); + Password newPassword = new Password("NewPass123!@"); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(validLoginId, wrongPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void changePassword_whenNewPasswordSameAsCurrent() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + Password samePassword = new Password("Test1234!@#"); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, samePassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void changePassword_whenNewPasswordContainsBirthDate() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + Password newPasswordWithBirthDate = new Password("Pw19900115!"); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, newPasswordWithBirthDate)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void changePassword_whenUserNotFound() { + // arrange + LoginId invalidLoginId = new LoginId("invalid123"); + Password newPassword = new Password("NewPass123!@"); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(invalidLoginId, validPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasMessageContaining("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } } 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 index 9355bbd9..c81b0f5b 100644 --- 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 @@ -444,4 +444,236 @@ void throwsError_whenAuthenticationHeadersMissing() { assertTrue(response.getStatusCode().is4xxClientError() || response.getStatusCode().is5xxServerError()); } } + + @DisplayName("PATCH /api/v1/users/password") + @Nested + class ChangePassword { + + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @DisplayName("์œ ํšจํ•œ ์ธ์ฆ ํ—ค๋”์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค") + @Test + void changePassword_whenValidRequest() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "Test1234!", + "NewPass123!@" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().message()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void changePassword_whenCurrentPasswordNotMatch() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "Wrong1234!", + "NewPass123!@" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void changePassword_whenNewPasswordSameAsCurrent() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + 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 + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void changePassword_whenNewPasswordContainsBirthDate() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "Test1234!", + "Pw19900101!" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("์ž˜๋ชป๋œ ์ธ์ฆ ํ—ค๋”๋กœ ์š”์ฒญํ•˜๋ฉด, 401 UNAUTHORIZED ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void changePassword_whenUnauthorized() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "Test1234!", + "NewPass123!@" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ˜•์‹์ด ์ž˜๋ชป๋˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") + @Test + void changePassword_whenInvalidPasswordFormat() { + // arrange + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "ํ™๊ธธ๋™", + "19900101", + "test@example.com" + ); + + ParameterizedTypeReference> signupResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + 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 + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } } From 641e21b5aa4bb25584f7003fc1c4137b9b0d37c0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:29:05 +0900 Subject: [PATCH 25/29] =?UTF-8?q?docs:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20HTTP=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=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 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- http/commerce-api/user-v1.http | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http index d16889d0..b68ce780 100644 --- a/http/commerce-api/user-v1.http +++ b/http/commerce-api/user-v1.http @@ -151,3 +151,73 @@ X-Loopers-LoginPw: Wrong1234! ### ๋‚ด ์ •๋ณด ์กฐํšŒ - ์ธ์ฆ ํ—ค๋” ๋ˆ„๋ฝ (์‹คํŒจ) GET {{commerce-api}}/api/v1/users/me + +############################################### +# ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ API +############################################### + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ์„ฑ๊ณต ์ผ€์ด์Šค +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass123!@" +} + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜ (์‹คํŒจ) +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Wrong1234!", + "newPassword": "NewPass123!@" +} + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผ (์‹คํŒจ) +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "Test1234!" +} + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ ํฌํ•จ (์‹คํŒจ) +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "Pw19900101!" +} + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ์ž˜๋ชป๋œ ์ธ์ฆ ํ—ค๋” (์‹คํŒจ) +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass123!@" +} + +### ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ˜•์‹ ์˜ค๋ฅ˜ (์‹คํŒจ) +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "short" +} From 521127d486fdf48853f8f1b279c96017e5b60298 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 03:52:44 +0900 Subject: [PATCH 26/29] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85-=EB=82=B4=EC=A0=95=EB=B3=B4=EC=A1=B0=ED=9A=8C-?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=EB=B3=80=EA=B2=BD-?= =?UTF-8?q?=EB=82=B4=EC=A0=95=EB=B3=B4=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/UserV1ApiScenarioTest.java | 171 ++++++++++++++++++ http/commerce-api/user-v1.http | 42 +++++ 2 files changed, 213 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java new file mode 100644 index 00000000..e15c7e65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java @@ -0,0 +1,171 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("User V1 API ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ") +class UserV1ApiScenarioTest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + private static final String ENDPOINT_MY_INFO = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiScenarioTest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์œ ์ € ์ „์ฒด ํ”Œ๋กœ์šฐ: ํšŒ์›๊ฐ€์ž… -> ๋‚ด ์ •๋ณด ์กฐํšŒ -> ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ -> ๋‚ด ์ •๋ณด ์กฐํšŒ") + @Test + void fullUserFlowScenario() { + String loginId = "testuser123"; + String originalPassword = "Test1234!@#"; + String newPassword = "NewPass123!@"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900115"; + String email = "test@example.com"; + + // ===== 1๋‹จ๊ณ„: ํšŒ์›๊ฐ€์ž… ===== + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + loginId, + originalPassword, + name, + birthDate, + email + ); + + ParameterizedTypeReference> signupResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> signupResponse = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupResponseType); + + // ํšŒ์›๊ฐ€์ž… ๊ฒ€์ฆ + assertAll( + "ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต ๊ฒ€์ฆ", + () -> assertTrue(signupResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(signupResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(signupResponse.getBody()).isNotNull(), + () -> assertThat(signupResponse.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(signupResponse.getBody().data().name()).isEqualTo("ํ™๊ธธ*"), + () -> assertThat(signupResponse.getBody().data().birthDate()).isEqualTo(birthDate), + () -> assertThat(signupResponse.getBody().data().email()).isEqualTo(email) + ); + + // ===== 2๋‹จ๊ณ„: ๋‚ด ์ •๋ณด ์กฐํšŒ (์›๋ž˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ) ===== + HttpHeaders headers1 = new HttpHeaders(); + headers1.set(HEADER_LOGIN_ID, loginId); + headers1.set(HEADER_LOGIN_PW, originalPassword); + + ParameterizedTypeReference> myInfoResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> myInfoResponse1 = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers1), myInfoResponseType); + + // ์ฒซ ๋ฒˆ์งธ ๋‚ด ์ •๋ณด ์กฐํšŒ ๊ฒ€์ฆ + assertAll( + "์ฒซ ๋ฒˆ์งธ ๋‚ด ์ •๋ณด ์กฐํšŒ ์„ฑ๊ณต ๊ฒ€์ฆ", + () -> assertTrue(myInfoResponse1.getStatusCode().is2xxSuccessful()), + () -> assertThat(myInfoResponse1.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(myInfoResponse1.getBody()).isNotNull(), + () -> assertThat(myInfoResponse1.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(myInfoResponse1.getBody().data().name()).isEqualTo("ํ™๊ธธ*"), + () -> assertThat(myInfoResponse1.getBody().data().birthDate()).isEqualTo(birthDate), + () -> assertThat(myInfoResponse1.getBody().data().email()).isEqualTo(email) + ); + + // ===== 3๋‹จ๊ณ„: ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ===== + HttpHeaders headers2 = new HttpHeaders(); + headers2.set(HEADER_LOGIN_ID, loginId); + headers2.set(HEADER_LOGIN_PW, originalPassword); + + UserV1Dto.ChangePasswordRequest changePasswordRequest = new UserV1Dto.ChangePasswordRequest( + originalPassword, + newPassword + ); + + ParameterizedTypeReference> changePasswordResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> changePasswordResponse = + testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers2), + changePasswordResponseType + ); + + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ๊ฒ€์ฆ + assertAll( + "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์„ฑ๊ณต ๊ฒ€์ฆ", + () -> assertTrue(changePasswordResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(changePasswordResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(changePasswordResponse.getBody()).isNotNull(), + () -> assertThat(changePasswordResponse.getBody().data().message()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ); + + // ===== 4๋‹จ๊ณ„: ๋‚ด ์ •๋ณด ์กฐํšŒ (์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ) ===== + HttpHeaders headers3 = new HttpHeaders(); + headers3.set(HEADER_LOGIN_ID, loginId); + headers3.set(HEADER_LOGIN_PW, newPassword); + + ResponseEntity> myInfoResponse2 = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers3), myInfoResponseType); + + // ๋‘ ๋ฒˆ์งธ ๋‚ด ์ •๋ณด ์กฐํšŒ ๊ฒ€์ฆ (์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ ์„ฑ๊ณต) + assertAll( + "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋‚ด ์ •๋ณด ์กฐํšŒ ์„ฑ๊ณต ๊ฒ€์ฆ", + () -> assertTrue(myInfoResponse2.getStatusCode().is2xxSuccessful()), + () -> assertThat(myInfoResponse2.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(myInfoResponse2.getBody()).isNotNull(), + () -> assertThat(myInfoResponse2.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(myInfoResponse2.getBody().data().name()).isEqualTo("ํ™๊ธธ*"), + () -> assertThat(myInfoResponse2.getBody().data().birthDate()).isEqualTo(birthDate), + () -> assertThat(myInfoResponse2.getBody().data().email()).isEqualTo(email) + ); + + // ===== 5๋‹จ๊ณ„: ์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ๋Š” ์ธ์ฆ ์‹คํŒจ ํ™•์ธ ===== + HttpHeaders headers4 = new HttpHeaders(); + headers4.set(HEADER_LOGIN_ID, loginId); + headers4.set(HEADER_LOGIN_PW, originalPassword); + + ResponseEntity> myInfoResponse3 = + testRestTemplate.exchange(ENDPOINT_MY_INFO, HttpMethod.GET, new HttpEntity<>(headers4), myInfoResponseType); + + // ์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ ์‹คํŒจ ๊ฒ€์ฆ + assertAll( + "์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ ์‹คํŒจ ๊ฒ€์ฆ", + () -> assertTrue(myInfoResponse3.getStatusCode().is4xxClientError()), + () -> assertThat(myInfoResponse3.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http index b68ce780..726e819a 100644 --- a/http/commerce-api/user-v1.http +++ b/http/commerce-api/user-v1.http @@ -221,3 +221,45 @@ Content-Type: application/json "currentPassword": "Test1234!", "newPassword": "short" } + +############################################### +# ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ (์ „์ฒด ํ”Œ๋กœ์šฐ) +############################################### + +### [STEP 1] ํšŒ์›๊ฐ€์ž… - ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ์šฉ ์‚ฌ์šฉ์ž +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "scenario123", + "password": "Test1234!@#", + "name": "ํ™๊ธธ๋™", + "birthDate": "19900115", + "email": "scenario@example.com" +} + +### [STEP 2] ๋‚ด ์ •๋ณด ์กฐํšŒ - ์›๋ž˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: scenario123 +X-Loopers-LoginPw: Test1234!@# + +### [STEP 3] ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +PATCH {{commerce-api}}/api/v1/users/password +X-Loopers-LoginId: scenario123 +X-Loopers-LoginPw: Test1234!@# +Content-Type: application/json + +{ + "currentPassword": "Test1234!@#", + "newPassword": "NewPass123!@" +} + +### [STEP 4] ๋‚ด ์ •๋ณด ์กฐํšŒ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ (์„ฑ๊ณต) +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: scenario123 +X-Loopers-LoginPw: NewPass123!@ + +### [STEP 5] ๋‚ด ์ •๋ณด ์กฐํšŒ - ์ด์ „ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์ธ์ฆ (์‹คํŒจ) +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: scenario123 +X-Loopers-LoginPw: Test1234!@# From 3b3ef73939547cf9e92d3e8b21ef41c1b7adbdb9 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 11:56:42 +0900 Subject: [PATCH 27/29] =?UTF-8?q?feat:=20=ED=8C=A8=EC=8A=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=95=94=ED=98=B8=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PasswordEncoder ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ BCrypt ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€ - Password.fromEncoded() ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ - UserService์—์„œ ํšŒ์›๊ฐ€์ž… ์‹œ ํŒจ์Šค์›Œ๋“œ ์•”ํ˜ธํ™” - AuthenticationService์—์„œ ์•”ํ˜ธํ™”๋œ ํŒจ์Šค์›Œ๋“œ ๋น„๊ต - ๊ด€๋ จ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜์ • ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/commerce-api/build.gradle.kts | 3 ++ .../loopers/domain/AuthenticationService.java | 5 ++- .../java/com/loopers/domain/Password.java | 8 ++++ .../java/com/loopers/domain/UserModel.java | 2 - .../java/com/loopers/domain/UserService.java | 9 ++++- .../BCryptPasswordEncoderImpl.java | 24 +++++++++++ .../infrastructure/PasswordEncoder.java | 6 +++ .../domain/AuthenticationServiceTest.java | 17 ++++++-- .../domain/UserServiceIntegrationTest.java | 40 ++++++++++++++++++- 9 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java 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/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java index 97734843..bf148ae1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java @@ -1,5 +1,6 @@ package com.loopers.domain; +import com.loopers.infrastructure.PasswordEncoder; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -10,15 +11,15 @@ public class AuthenticationService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; public UserModel authenticate(String loginIdValue, String rawPassword) { LoginId loginId = new LoginId(loginIdValue); - Password password = new Password(rawPassword); UserModel user = userRepository.find(loginId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); - if (!user.getPassword().equals(password)) { + if (!passwordEncoder.matches(rawPassword, user.getPassword().getValue())) { throw new CoreException(ErrorType.UNAUTHORIZED, "๋กœ๊ทธ์ธ ID ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java index 25143cf1..17d4ec1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -22,6 +22,14 @@ public Password(String value) { this.value = value; } + private Password(String value, boolean skipValidation) { + this.value = value; + } + + public static Password fromEncoded(String encodedValue) { + return new Password(encodedValue, true); + } + private void validate(String value) { if (value == null || !PASSWORD_PATTERN.matcher(value).matches()) { throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ์กฐํ•ฉ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index 931e95e6..963eca96 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -53,8 +53,6 @@ private void validate(LoginId loginId, Password password, Name name, BirthDate b validateNotNull(name, "์ด๋ฆ„"); validateNotNull(birthDate, "์ƒ๋…„์›”์ผ"); validateNotNull(email, "์ด๋ฉ”์ผ"); - - password.validateNotContainBirthday(birthDate); } private void validateNotNull(Object value, String fieldName) { if (value == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index 79dda8b7..0cd79b6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -1,5 +1,6 @@ package com.loopers.domain; +import com.loopers.infrastructure.PasswordEncoder; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; @@ -10,6 +11,7 @@ @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; @Transactional public UserModel signup(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { @@ -18,7 +20,12 @@ public UserModel signup(LoginId loginId, Password password, Name name, BirthDate throw new CoreException(ErrorType.BAD_REQUEST,"์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); } - UserModel userModel = new UserModel(loginId,password,name,birthDate,email); + password.validateNotContainBirthday(birthDate); + + String encodedPasswordValue = passwordEncoder.encode(password.getValue()); + Password encryptedPassword = Password.fromEncoded(encodedPasswordValue); + + UserModel userModel = new UserModel(loginId,encryptedPassword,name,birthDate,email); return userRepository.save(userModel); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java new file mode 100644 index 00000000..6545f186 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BCryptPasswordEncoderImpl implements PasswordEncoder { + + private final BCryptPasswordEncoder delegate; + + public BCryptPasswordEncoderImpl() { + this.delegate = new BCryptPasswordEncoder(); + } + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java new file mode 100644 index 00000000..fbe0ff62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure; + +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java index 234ba177..1cf99940 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -25,21 +25,26 @@ class AuthenticationServiceTest { @Mock private UserRepository userRepository; + @Mock + private com.loopers.infrastructure.PasswordEncoder passwordEncoder; + @InjectMocks private AuthenticationService authenticationService; private UserModel testUser; private String validLoginId; private String validPassword; + private String encodedPassword; @BeforeEach void setUp() { validLoginId = "testuser123"; validPassword = "Test1234!@#"; + encodedPassword = "$2a$10$encodedPasswordHash"; testUser = new UserModel( new LoginId(validLoginId), - new Password(validPassword), + Password.fromEncoded(encodedPassword), new Name("ํ™๊ธธ๋™"), new BirthDate(LocalDate.of(1990, 1, 15)), new Email("test@example.com") @@ -55,6 +60,7 @@ class Authenticate { void authenticate_should_return_user_when_credentials_are_correct() { // arrange when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(validPassword, encodedPassword)).thenReturn(true); // act UserModel result = authenticationService.authenticate(validLoginId, validPassword); @@ -83,6 +89,7 @@ void authenticate_should_throw_exception_when_password_is_incorrect() { // arrange when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); String wrongPassword = "Wrong1234!@#"; + when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, wrongPassword)) @@ -92,12 +99,16 @@ void authenticate_should_throw_exception_when_password_is_incorrect() { } @Test - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ null์ด๋ฉด BAD_REQUEST ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ null์ด๋ฉด UNAUTHORIZED ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") void authenticate_should_throw_exception_when_password_is_null() { + // arrange + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches(null, encodedPassword)).thenReturn(false); + // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, null)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index 9b9f538d..a272c114 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; +import com.loopers.infrastructure.PasswordEncoder; import com.loopers.infrastructure.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -25,11 +26,15 @@ public class UserServiceIntegrationTest { @Autowired private UserJpaRepository userJpaRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired private DatabaseCleanUp databaseCleanUp; private LoginId validLoginId; private Password validPassword; + private String rawPassword; private Name validName; private BirthDate validBirthDate; private Email validEmail; @@ -37,7 +42,8 @@ public class UserServiceIntegrationTest { @BeforeEach void setUp() { validLoginId = new LoginId("testuser123"); - validPassword = new Password("Test1234!@#"); + rawPassword = "Test1234!@#"; + validPassword = new Password(rawPassword); validName = new Name("ํ™๊ธธ๋™"); validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); validEmail = new Email("test@example.com"); @@ -61,12 +67,42 @@ void signup_whenAllInfoProvided() { assertAll( () -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(result.getPassword()).isEqualTo(validPassword), () -> assertThat(result.getName()).isEqualTo(validName), () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), () -> assertThat(result.getEmail()).isEqualTo(validEmail) ); } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์•”ํ˜ธํ™”ํ•˜์—ฌ ์ €์žฅํ•œ๋‹ค") + @Test + void signup_should_encrypt_password() { + // act + UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + + // assert + String savedPassword = result.getPassword().getValue(); + assertAll( + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // ํ‰๋ฌธ๊ณผ ๋‹ค๋ฆ„ + () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt ํฌ๋งท + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // ํ‰๋ฌธ๊ณผ ๋งค์นญ๋จ + ); + } + + @DisplayName("DB์— ์ €์žฅ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•”ํ˜ธํ™”๋˜์–ด ์žˆ๋‹ค") + @Test + void signup_should_save_encrypted_password_to_database() { + // act + UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + + // assert + UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); + String savedPassword = savedUser.getPassword().getValue(); + assertAll( + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // ํ‰๋ฌธ๊ณผ ๋‹ค๋ฆ„ + () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt ํฌ๋งท + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // ํ‰๋ฌธ๊ณผ ๋งค์นญ๋จ + ); + } } @DisplayName("์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด๋ฅผ ์กฐํšŒํ•  ๋•Œ") From fd18f15f502867ff5f5ff8a88b156ef9b376afe3 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 12:02:27 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix:=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=8B=9C=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserService์—์„œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์‹œ ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋น„๊ต - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋„ ์•”ํ˜ธํ™”ํ•˜์—ฌ ์ €์žฅ - UserModel.changePassword๋Š” ๋‹จ์ˆœ ํ•„๋“œ ๋ณ€๊ฒฝ๋งŒ ์ˆ˜ํ–‰ - UserModelTest์˜ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ด€๋ จ ํ…Œ์ŠคํŠธ ์ œ๊ฑฐ (๋กœ์ง์ด UserService๋กœ ์ด๋™) - UserServiceIntegrationTest ์•”ํ˜ธํ™”๋ฅผ ๊ณ ๋ คํ•œ ๊ฒ€์ฆ์œผ๋กœ ์ˆ˜์ • ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/com/loopers/domain/UserModel.java | 8 +- .../java/com/loopers/domain/UserService.java | 18 ++++- .../com/loopers/domain/UserModelTest.java | 74 ------------------- .../domain/UserServiceIntegrationTest.java | 7 +- 4 files changed, 24 insertions(+), 83 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index 963eca96..4c76f23d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -61,13 +61,7 @@ private void validateNotNull(Object value, String fieldName) { } public void changePassword(Password currentPassword, Password newPassword) { - if (!this.password.equals(currentPassword)) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - newPassword.validateNotSameAs(currentPassword); - newPassword.validateNotContainBirthday(this.birthDate); - + // ๊ฒ€์ฆ์€ UserService์—์„œ ์ˆ˜ํ–‰ this.password = newPassword; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index 0cd79b6f..874c1f87 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -40,7 +40,23 @@ public void changePassword(LoginId loginId, Password currentPassword, Password n UserModel user = userRepository.find(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - user.changePassword(currentPassword, newPassword); + // ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ + if (!passwordEncoder.matches(currentPassword.getValue(), user.getPassword().getValue())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ + if (passwordEncoder.matches(newPassword.getValue(), user.getPassword().getValue())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + newPassword.validateNotContainBirthday(user.getBirthDate()); + + // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ๋ฐ ์ €์žฅ + String encodedNewPassword = passwordEncoder.encode(newPassword.getValue()); + Password encryptedNewPassword = Password.fromEncoded(encodedNewPassword); + + user.changePassword(Password.fromEncoded(user.getPassword().getValue()), encryptedNewPassword); userRepository.save(user); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index 9e6fda09..6082a9ef 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -82,79 +82,5 @@ void createUserModel_whenEmailIsNull() { assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, validBirthDate, null)) .isInstanceOf(CoreException.class); } - - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void createUserModel_whenPasswordContainsBirthDate() { - // arrange - BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - Password passwordWithBirthDate = new Password("Pw19900115!"); - - // expect - assertThatThrownBy(() -> new UserModel(validLoginId, passwordWithBirthDate, validName, birthDate, validEmail)) - .isInstanceOf(CoreException.class); - } - } - - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•  ๋•Œ, ") - @Nested - class ChangePassword { - - private UserModel user; - - @BeforeEach - void setUp() { - user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); - } - - @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ฃผ๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") - @Test - void changePassword_whenValidPasswords() { - // arrange - Password newPassword = new Password("NewPass123!@"); - - // act - user.changePassword(validPassword, newPassword); - - // assert - assertThat(user.getPassword()).isEqualTo(newPassword); - } - - @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void changePassword_whenCurrentPasswordNotMatch() { - // arrange - Password wrongPassword = new Password("Wrong123!@#"); - Password newPassword = new Password("NewPass123!@"); - - // act & assert - assertThatThrownBy(() -> user.changePassword(wrongPassword, newPassword)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋™์ผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void changePassword_whenNewPasswordSameAsCurrent() { - // arrange - Password samePassword = new Password("Test1234!@#"); - - // act & assert - assertThatThrownBy(() -> user.changePassword(validPassword, samePassword)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void changePassword_whenNewPasswordContainsBirthDate() { - // arrange - Password newPasswordWithBirthDate = new Password("Pw19900115!"); - - // act & assert - assertThatThrownBy(() -> user.changePassword(validPassword, newPasswordWithBirthDate)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index a272c114..67067309 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -156,7 +156,12 @@ void changePassword_whenValidPasswords() { // assert UserModel updatedUser = userService.getMyInfo(validLoginId); - assertThat(updatedUser.getPassword()).isEqualTo(newPassword); + String savedPassword = updatedUser.getPassword().getValue(); + assertAll( + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // ์ด์ „ ํ‰๋ฌธ๊ณผ ๋‹ค๋ฆ„ + () -> assertThat(savedPassword).isNotEqualTo(newPassword.getValue()), // ์ƒˆ ํ‰๋ฌธ๊ณผ๋„ ๋‹ค๋ฆ„ (์•”ํ˜ธํ™”๋จ) + () -> assertThat(passwordEncoder.matches(newPassword.getValue(), savedPassword)).isTrue() // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋งค์นญ๋จ + ); } @DisplayName("ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") From 899e211f5e1110cc6d08754b3618f4d227f9b9d7 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 6 Feb 2026 14:34:25 +0900 Subject: [PATCH 29/29] =?UTF-8?q?docs:=20=EA=B3=BC=EC=A0=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EA=B3=BC=EC=A0=95=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 76 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 30d2f701..49ee650a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Round-1 ๋ชฉํ‘œ +# Round-1 +### ์‹œ์ž‘ ์ „ ๋ชฉํ‘œ ``` - ๋‚˜์˜ ์˜๋„๋ฅผ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋กœ ์ž‘์„ฑํ•œ๋‹ค. - TDD ๋ฐฉ์‹์œผ๋กœ AI์™€ ํ•จ๊ป˜ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. @@ -7,38 +8,67 @@ - ๋ฆฌํŒฉํ† ๋ง์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋Š”๊ฒƒ์„ ๋А๊ปด๋ณธ๋‹ค. ``` -## 1. ์‹œ์ž‘์ „, ๊ถ๊ธˆ์ฆ๋“ค +### ์‹œ์ž‘ ํ›„ ๋ชฉํ‘œ +- ์š”๊ตฌ์‚ฌํ•ญ์„ AI ์‹คํ–‰ ํ”„๋กฌํ”„ํŠธ๋กœ ๊ตฌ์กฐํ™”ํ•˜๋Š” ๋ฐฉ๋ฒ• ์•Œ์•„๋ณด๊ธฐ +``` +๋‚ด ํ˜„์žฌ ํ•™์Šต ์ดˆ์ ์€ '์„ค๊ณ„๋Š” ๋‚ด๊ฐ€, ๊ตฌํ˜„์€ AI๊ฐ€'๋ผ๋Š” ๋ช…ํ™•ํ•œ ์—ญํ•  ๋ถ„๋ฆฌ๋‹ค. +TDD ๋ฐฉ์‹์œผ๋กœ AI์—๊ฒŒ ์ฝ”๋”ฉ์„ ์œ„์ž„ํ•˜๋ฉด์„œ๋„ ์„ค๊ณ„ ๊ฒฐ์ •๊ถŒ์€ ๋‚ด๊ฐ€ ๊ฐ€์ ธ๊ฐ€๋Š” ๋ฐฉ์‹์ด๋‹ค. +์—ฌ๊ธฐ์„œ ์งˆ๋ฌธ์ด ์ƒ๊ฒผ๋Š”๋ฐ, ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ฐ›์•˜์„ ๋•Œ, ์–ด๋–ค ๋ณ€ํ™˜ ๊ณผ์ •์„ ๊ฑฐ์ณ AI๊ฐ€ ์ •ํ™•ํžˆ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กฌํ”„ํŠธ ํ˜•ํƒœ๋กœ ๋งŒ๋“ค์–ด์ง€๋Š”๊ฐ€?' + +์ด ๋ณ€ํ™˜ ๊ณผ์ •์„ ์•Œ์•„๋ณด๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ–ˆ๋‹ค. +``` -### 1. ๋ชฉํ‘œ๋ฅผ ์„ธ์šฐ๋ฉฐ -- ์ด ๊ณผ์ •์—์„œ ๋‚˜๋Š” ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๊ฐ€ ๋ฌด์—‡์ธ์ง€ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ์„๊นŒ? - - ์–ด๋А ๊ณผ์ •์—์„œ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ผ๊ณ  ๋А๊ผˆ์„๊นŒ? +### ๋‚ด๊ฐ€ ํ•œ ์‹œ๋„ -### 2. ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ -- ๋ฉ€ํ‹ฐ๋ชจ๋“ˆ? ์ด๊ฒŒ ๋ญ์ง€? ์™œ ์ด ๊ตฌ์กฐ๋กœ ๋‚˜๋ˆด์–ด์•ผ ํ–ˆ์ง€? -- ๊ฐ ๋ชจ๋“ˆ์˜ ๋„ค์ด๋ฐ์—๋Š” -api , -batch, - streamer ๋ผ๊ณ  ๋ถ™์—ฌ์ ธ ์žˆ๋Š”๋ฐ ์™œ ์ด๋ ‡๊ฒŒ ๋ถ™์ธ๊ฑฐ์ง€? +์‹œ๋„ 1: AI๊ฐ€ ์„ค๊ณ„ํ•˜๊ณ , AI๊ฐ€ ๊ฒฐ์ • +- ๋ฐฉ์‹: ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ทธ๋Œ€๋กœ ๋˜์ ธ์ฃผ๊ณ  TDD(Red-Green-Refactor)๋กœ ๊ฐœ๋ฐœํ•˜๋ผ๊ณ  ํ–ˆ๋‹ค. +- ๋ฌธ์ œ์ : ์ด ๊ณผ์ •์—์„œ ๋‚ด ์„ค๊ณ„๋„, ๋‚ด ์˜๋„๋„ ์—†๋‹ค๋Š” ๊ฑธ ์ž๊ฐํ–ˆ๋‹ค. AI๊ฐ€ ์•Œ์•„์„œ ๋‹ค ํ–ˆ์„ ๋ฟ์ด์—ˆ๋‹ค. +์‹œ๋„ 2: AI๊ฐ€ ์„ค๊ณ„ํ•˜๊ณ , ๋‚ด๊ฐ€ ๊ฒฐ์ • +- ๋ฐฉ์‹: AI๊ฐ€ ์„ค๊ณ„ ๋ณด๊ณ ์„œ๋ฅผ ์ž‘์„ฑํ•˜๋ฉด ๋‚ด๊ฐ€ ์ฝ๊ณ  ๊ฒฐ์ •ํ•˜๋Š” ์œ„์น˜์— ์„œ๊ธฐ๋กœ ํ–ˆ๋‹ค. +- ๋ฌธ์ œ์ : + - AI๊ฐ€ ๋„ˆ๋ฌด ๋ณต์žกํ•œ ์„ค๊ณ„๋ฅผ ์ œ์‹œํ–ˆ๋‹ค. + - ๊ทธ ๋ฌธ์„œ๋ฅผ ์ฝ๋Š” ๋ฐ ์‹œ๊ฐ„์ด ๋งŽ์ด ๋“ค์—ˆ๊ณ , ์ „๋ถ€ ์ฝ์„ ์ˆ˜๋„ ์—†์—ˆ๋‹ค. + - ๋‚ด ์˜๋„๊ฐ€ ๋‹ด๊ธด ์„ค๊ณ„๋ผ๊ณ  ์ „ํ˜€ ๋А๊ปด์ง€์ง€ ์•Š์•˜๋‹ค. -### 3. ๋ชจ๋“ˆ ์•„ํ‚คํ…์ฒ˜(commerce-api) -- ์™œ Controller ํŒจํ‚ค์ง€๋ช…์ด ์•„๋‹Œ, interfaces๋ผ๊ณ  ํ•˜์ง€? ์ง€๊ธˆ์€ ์ปจํŠธ๋กค๋Ÿฌ ํ•˜๋‚˜ ๋ฟ์ธ๋ฐ? ํ™•์žฅ์„ฑ์„ ์œ„ํ•ด ๋จผ์ € ์ด๋ ‡๊ฒŒ ๋„ค์ด๋ฐ ์ง“๋Š”๊ฒŒ ์œ ๋ฆฌํ•œ๊ฐ€? -- ๊ณ„์ธต๊ฐ„ DTO์˜ ๋„ค์ด๋ฐ์€ ์–ด๋–ป๊ฒŒ ์ง€์–ด์•ผํ• ๊นŒ? -- DTO๋ฅผ ๋‚ด๋ถ€ํด๋ž˜์Šค๋กœ ๊ด€๋ฆฌํ•˜๊ณ  ์žˆ๋„ค? ํŒŒ์ผ ์ˆ˜๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•จ์ธ๊ฐ€? ์™œ ์ด๋ ‡๊ฒŒ ํ•œ๊ฑฐ์ง€? ์ด๊ฒŒ ํ˜„์—… ๋ฐฉ์‹์ธ๊ฐ€? -- ์™œ ์˜จ๋ณด๋”ฉ ํ”„๋กœ์ ํŠธ์— JPA๋ฅผ ์‚ฌ์šฉํ•œ๊ฑฐ์ง€? ์‰ฝ๊ณ  ๋‹ค๋“ค ์‚ฌ์šฉํ•˜๋‹ˆ๊นŒ? +์‹œ๋„ 3: ๋‚ด๊ฐ€ ์„ค๊ณ„ํ•˜๊ณ , AI์˜ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›๊ธฐ +- ๋ฐฉ์‹: ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ๋ถ€ํ„ฐ ๊ฐ์ฒด์™€ ๋ฉ”์‹œ์ง€ ์ •์˜, ๊ฒ€์ฆ ๋ฐ ์˜ˆ์™ธ ์ผ€์ด์Šค๋ฅผ ๋‚ด๊ฐ€ ์ง์ ‘ ๋ฌธ์„œ๋กœ ์ •๋ฆฌํ–ˆ๋‹ค. +- ์ข‹์•˜๋˜ ์ : + - ์˜ค์ง ๋‚ด ์„ค๊ณ„ ํ‹€ ์•ˆ์—์„œ AI๊ฐ€ ๊ณ ๋ คํ•ด์ค˜์„œ, ์œ ์šฉํ•œ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์•˜๋‹ค. + - ๋ ˆ์ด์–ด๋ณ„ ํŠน์ง•, ํ•ด์•ผ ํ•  ๊ฒƒ, ํ•˜์ง€ ๋ง์•„์•ผ ํ•  ๊ฒƒ์„ ๋จผ์ € ์ •์˜ํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฑธ ๋ฐฐ์› ๋‹ค. + - ๋‚ด๊ฐ€ ๋†“์นœ ์—ฃ์ง€ ์ผ€์ด์Šค๋‚˜ ์ž ์žฌ์  ๋ฌธ์ œ๋ฅผ ํ•ด์ƒ๋„ ๋†’๊ฒŒ ์•Œ๋ ค์ฃผ๊ณ , ํŒ๋‹จ์„ ์š”๊ตฌํ•ด์คฌ๋‹ค. +- ๋ฌธ์ œ์ : + - ํšŒ์›๊ฐ€์ž… ๊ธฐ๋Šฅ์„ ๋„๋ฉ”์ธ๋ถ€ํ„ฐ API๊นŒ์ง€ ์ „๋ถ€ ์ˆ˜๋„์ฝ”๋“œ๋กœ ๋ฌธ์„œํ™”ํ•˜๊ณ  ์žˆ์—ˆ๋Š”๋ฐ, ์ด๋Ÿฌ๋‹ค ๋ณด๋‹ˆ ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค. + - "์ˆ˜๋„์ฝ”๋“œ๋กœ ๋ฌธ์„œํ™”ํ•  ๋ฐ”์—, ๊ทธ๋ƒฅ ๋‚ด๊ฐ€ ์ฝ”๋“œ๋กœ ๋จผ์ € ์ž‘์„ฑํ•˜๊ณ  ์ด ์Šคํƒ€์ผ๋Œ€๋กœ ๋‚˜๋จธ์ง€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋ผ๊ณ  ํ•˜๋Š” ๊ฒŒ ๋” ๋‚ด ์˜๋„๊ฐ€ ๋‹ด๊ธด ์ฝ”๋“œ ์•„๋‹Œ๊ฐ€?" + - ์š”๊ตฌ์‚ฌํ•ญ ๊ทœ์น™๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ๊ฐœ๋ฐœ ๊ทœ์น™๋“ค์„ ๋ช…ํ™•ํžˆ ํ•˜๋Š” ๊ฒƒ์ด ๋ถ€์กฑํ–ˆ๋‹ค. + - ๋‚ด ์˜๋„๋ฅผ ์„ค๋ช…ํ•˜๋ฉด์„œ ๊ฐœ๋ฐœ ์Šคํƒ€์ผ์„ ์•Œ๋ ค์ค„ ๋•Œ, ์ง€๊ธˆ์ฒ˜๋Ÿผ ๋ฌธ์„œ๋กœ ์ „๋‹ฌํ•˜๋Š” ๊ฒŒ ๋งž๋Š”์ง€, ์•„๋‹ˆ๋ฉด ๋‚ด๊ฐ€ ๊ธฐ๋Šฅ ๊ตฌํ˜„ ํ•˜๋‚˜๋ฅผ ์˜ˆ์‹œ๋กœ ๋งŒ๋“ค์–ด์ฃผ๊ณ  ์ด ์Šคํƒ€์ผ์„ ์ฐธ๊ณ ํ•ด์„œ ๊ฐœ๋ฐœํ•˜๋ผ๊ณ  ํ•ด์•ผ ํ• ์ง€ ๊ณ ๋ฏผ์ด ๋“ค์—ˆ๋‹ค. +์‹œ๋„ 4: ๋‚ด๊ฐ€ ์„ค๊ณ„ํ•˜๊ณ  ์‹œ๋ฒ” ๊ตฌํ˜„์„ ์ž‘์„ฑํ•˜๊ณ , AI์—๊ฒŒ ์ด ๋ฐฉ์‹๋Œ€๋กœ ํ•˜๋ผ๊ณ  ๋ช…๋ น +- ๋ฐฉ์‹: ํšŒ์›๊ฐ€์ž… ๊ธฐ๋Šฅ์„ TDD(Red-Green-Refactor)๋กœ ๋„๋ฉ”์ธ์—์„œ API๊นŒ์ง€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๊ณ , ๋‚ด ์ฝ”๋“œ ์Šคํƒ€์ผ๋Œ€๋กœ ๋‹ค๋ฅธ ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ์„ ๋งก๊ฒผ๋‹ค. +- ์ง„ํ–‰ ๊ณผ์ •: + - AI๋Š” A๋ถ€ํ„ฐ Z๊นŒ์ง€ ์ „๋ถ€ ๋งŒ๋“ค์–ด๋ƒˆ๋‹ค. + - ๋‚˜๋Š” ์™œ ๊ทธ๋ ‡๊ฒŒ ์„ค๊ณ„ํ–ˆ๊ณ  ๋งŒ๋“ค์—ˆ๋Š”์ง€ ๋ฌผ์–ด๋ณด๋Š” ์‹์œผ๋กœ AI์˜ ์„ค๊ณ„ ์‚ฌ๊ณ ๋ฅผ ๋ฐฐ์šฐ๊ณ  ์žˆ์—ˆ๋‹ค. + - ์ด ๋‚ด์šฉ๋“ค์€ ๋‚ด๊ฐ€ ์„ค๊ณ„๋ฅผ ์ง„ํ–‰ํ–ˆ์–ด๋„ AI์—๊ฒŒ ๋ฌผ์–ด๋ณผ ๋‚ด์šฉ๋“ค์ด์—ˆ๋‹ค. + - ๋‚ด ์˜๋„๊ฐ€ ๋‹ด๊ธด ์ฝ”๋“œ๋ผ๊ธฐ๋ณด๋‹ค๋Š”, ๋‚˜๋Š” ๊ทธ ์˜๋„๋ฅผ ์ดํ•ดํ•ด ๋‚˜๊ฐ€๊ณ , ๋™์˜ํ•˜์ง€ ์•Š์œผ๋ฉด ๋‚ด ์„ค๊ณ„๋ฅผ ๋‹ด์•„๋‚ด๋Š” ์‹์œผ๋กœ ์ง„ํ–‰ํ–ˆ๋‹ค. +- ๋ฌธ์ œ์ : + - ํ•œ ๋ฒˆ ๊ตฌํ˜„ํ•˜๋ผ๊ณ  ํ•  ๋•Œ๋งˆ๋‹ค ์˜ค๋ž˜ ๊ฑธ๋ ธ๋‹ค. ์ฒ˜์Œ๋ถ€ํ„ฐ API๊นŒ์ง€ ๊ด€๋ จ ๋ชจ๋“  ์ฝ”๋“œ๋ฅผ 20๋ถ„ ๋™์•ˆ ์ž‘์—…ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. + - ์ „๋ถ€ ํ•œ ํ์— ์™„์„ฑ์‹œํ‚ค๊ธฐ ๋•Œ๋ฌธ์— ์–‘์ด ๋ฐฉ๋Œ€ํ–ˆ๋‹ค. + - ๋‚˜๋Š” ์ถ”๊ฐ€๋œ main ์ฝ”๋“œ ํ™•์ธ๊ณผ ํ…Œ์ŠคํŠธ ํ†ต๊ณผ ์—ฌ๋ถ€๋งŒ ํ™•์ธํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. + - ์ด๊ฒŒ ๋งž๋Š” ๋ฐฉ์‹์ธ์ง€๋Š” ์•„์ง ์ž˜ ๋ชจ๋ฅด๊ฒ ๋‹ค. -## 2. Round1 ๋ฌธ์„œ๋ฅผ ์ฝ์œผ๋ฉฐ, ์•Œ๊ฒŒ๋œ ๋ถ€๋ถ„ -### 1. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๋ฐ ๋”๋ธ” -- ๋‚˜๋Š” ํ…Œ์ŠคํŠธ ๋ ˆ์ด์–ด๋ณ„ ์–ด๋–ค ๊ธฐ์ˆ ๋“ค์„ ์‚ฌ์šฉํ•ด์•ผํ•˜๋Š”์ง€ ์ž˜ ๋ชจ๋ฅธ๋‹ค. -- ํ…Œ์ŠคํŠธ ์ž‘์„ฑ ์‹œ, ๋ ˆ์ด์–ด๋ณ„ ํ…Œ์ŠคํŠธ ๊ธฐ์ˆ ๋“ค์— ์ต์ˆ™ํ•ด์ง€๋„๋กํ•œ๋‹ค. -- Mock(๋Œ€์—ญ ๊ฐ์ฒด)๋ฅผ ์˜๋ฏธํ•œ๋‹ค. ์ด ๊ธฐ์ˆ ์„ ์ž˜ ๋ชจ๋ฅธ๋‹ค. +### ๊ณผ์ œ ํ›„ ๋А๋‚€์  +- AI ํ˜‘์—…์— ๋Œ€ํ•œ ๋ฏธํ•ด๊ฒฐ ๊ณ ๋ฏผ + - ์•„์ง ์–ด๋–ป๊ฒŒ AI๋ฅผ ํŒŒํŠธ๋„ˆ๋กœ ํ˜‘์—…ํ•˜๋Š” ๊ฒƒ์ธ์ง€ ๊นจ๋‹ซ์ง€ ๋ชปํ–ˆ๋‹ค. + - ์ด๋ฒˆ ๊ณผ์ œ๋Š” ์‚ฌ์‹ค ์ฒ˜์Œ๋ถ€ํ„ฐ ์™„๋ฒฝํ•œ ์„ค๊ณ„๋ฅผ ๋งŒ๋“ค๊ณ  AI์—๊ฒŒ ๊ฐœ๋ฐœํ•˜๋ผ๊ณ  ํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ๋ถ€ํ„ฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ์ง์ ‘ TDD๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉด์„œ ์ด ๊ณผ์ •์—์„œ ๊ถ๊ธˆํ•˜๊ฑฐ๋‚˜, ๋ง‰ํžˆ๊ฑฐ๋‚˜, ๋…ธ๊ฐ€๋‹ค ๊ณผ์ •์„ AI์—๊ฒŒ ๋งก๊ธฐ๋Š” ๊ฒƒ์„ ๊ธฐ๋Œ€ํ•œ ๊ณผ์ œ์˜€๋˜ ๊ฑธ๊นŒ? +- ํด๋กœ๋“œ ์ฝ”๋“œ ์‚ฌ์šฉ ๊ฒฝํ—˜ + - ์ด๋ฒˆ์— ํด๋กœ๋“œ ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๊ฐœ๋ฐœํ•ด๋ณด๋Š” ๊ฑด ์ฒ˜์Œ์ธ๋ฐ, ํ•œ ๋ฒˆ์˜ ๋ช…๋ น์œผ๋กœ A๋ถ€ํ„ฐ Z๊นŒ์ง€ ๊ธฐ๋Šฅ ๊ตฌํ˜„, ๋ฌธ์„œํ™”, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ „๋ถ€ ๊ตฌํ˜„ํ•ด์ค˜์„œ ๋†€๋ผ์› ๋‹ค. + - ๋” ์ด์ƒ ๊ตฌํ˜„์€ ์ค‘์š”ํ•˜์ง€ ์•Š๋‹ค๋Š” ๊ฒƒ์„ ์ด๋ฒˆ์— ์ฒด๊ฐํ–ˆ๋‹ค. + - ๋Œ€์‹  ๊ฐœ๋ฐœ์ด ๋˜๋Š” ํ™˜๊ฒฝ์„ ์ž˜ ์ดํ•ดํ•˜๋Š” ๊ฒƒ, ๊ณ„์ธต ์ฑ…์ž„์ด๋‚˜ ๊ฐ์ฒด ์ฑ…์ž„ ๋ฐ ๊ฒ€์ฆ ์Šค์ฝ”ํ”„๋ฅผ ์ž˜ ์ •์˜ํ•˜๋Š” ๊ฒƒ, ์ด๋Ÿฐ ๊ฒƒ๋“ค์ด ๋” ์ค‘์š”ํ•œ ๊ฒƒ ๊ฐ™์€ ๋А๋‚Œ์„ ๋ฐ›์•˜๋‹ค. + - ํด๋กœ๋“œ ์Šคํ‚ฌ์— ๊ด€์‹ฌ์ด ์ƒ๊ฒผ๋‹ค. -### 2. ํ…Œ์ŠคํŠธ ๋”๋ธ” -- Mock(๋Œ€์—ญ ๊ฐ์ฒด)๋ฅผ ์˜๋ฏธํ•œ๋‹ค. ์ด ๊ธฐ์ˆ ์„ ์ž˜ ๋ชจ๋ฅธ๋‹ค. -- ํ…Œ์ŠคํŠธ ๋”๋ธ” ์‚ฌ์šฉ ์›์น™ ๋ฐ ์ต์ˆ™ํ•ด์ง€๋„๋ก ์ง‘์ค‘ํ•œ๋‹ค. -### 3. TDD ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ์˜ ํŠน์ง• -- ๊ฐ€์žฅ ๊ฑฑ์ •๋˜๋Š”๊ฑด ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ถ„๋ฆฌ์—์„œ ์–ด๋–ค ๊ธฐ์ค€์œผ๋กœ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ์ชฝ์— ๋กœ์ง์„ ๋‘˜์ง€, ์„œ๋น„์Šค์— ๋‘˜์ง€ ํŒ๋‹จํ•˜๋Š” ๊ธฐ์ค€ ---