diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## πŸ§ͺ Implementation Quest - -> μ§€μ •λœ **λ‹¨μœ„ ν…ŒμŠ€νŠΈ / 톡합 ν…ŒμŠ€νŠΈ / E2E ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€**λ₯Ό ν•„μˆ˜λ‘œ κ΅¬ν˜„ν•˜κ³ , λͺ¨λ“  ν…ŒμŠ€νŠΈλ₯Ό ν†΅κ³Όμ‹œν‚€λŠ” 것을 λͺ©ν‘œλ‘œ ν•©λ‹ˆλ‹€. - -### νšŒμ› κ°€μž… - -**🧱 λ‹¨μœ„ ν…ŒμŠ€νŠΈ** - -- [ ] ID κ°€ `영문 및 숫자 10자 이내` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 이메일이 `xx@yy.zz` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 생년월일이 `yyyy-MM-dd` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ‹œ User μ €μž₯이 μˆ˜ν–‰λœλ‹€. ( spy 검증 ) -- [ ] 이미 κ°€μž…λœ ID 둜 νšŒμ›κ°€μž… μ‹œλ„ μ‹œ, μ‹€νŒ¨ν•œλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ΄ 성곡할 경우, μƒμ„±λœ μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] νšŒμ› κ°€μž… μ‹œμ— 성별이 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### λ‚΄ 정보 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, νšŒμ› 정보가 λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] λ‚΄ 정보 μ‘°νšŒμ— 성곡할 경우, ν•΄λ‹Ήν•˜λŠ” μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID 둜 μ‘°νšŒν•  경우, `404 Not Found` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### 포인트 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, 보유 ν¬μΈνŠΈκ°€ λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] 포인트 μ‘°νšŒμ— 성곡할 경우, 보유 포인트λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] `X-USER-ID` 헀더가 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. diff --git a/.gitignore b/.gitignore index 5a979af6..88ce09aa 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +.claude/ + +### Documentation ### +docs/ diff --git a/README.md b/README.md index f86e4dd8..49ee650a 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,77 @@ -# Loopers Template (Spring + Java) -Loopers μ—μ„œ μ œκ³΅ν•˜λŠ” μŠ€ν”„λ§ μžλ°” ν…œν”Œλ¦Ώ ν”„λ‘œμ νŠΈμž…λ‹ˆλ‹€. - -## Getting Started -ν˜„μž¬ ν”„λ‘œμ νŠΈ μ•ˆμ •μ„± 및 μœ μ§€λ³΄μˆ˜μ„± 등을 μœ„ν•΄ μ•„λž˜μ™€ 같은 μž₯치λ₯Ό μš΄μš©ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. 이에 μ•„λž˜ λͺ…λ Ήμ–΄λ₯Ό 톡해 ν”„λ‘œμ νŠΈμ˜ κΈ°λ°˜μ„ μ„€μΉ˜ν•΄μ£Όμ„Έμš”. -### Environment -`local` ν”„λ‘œν•„λ‘œ λ™μž‘ν•  수 μžˆλ„λ‘, ν•„μš” 인프라λ₯Ό `docker-compose` 둜 μ œκ³΅ν•©λ‹ˆλ‹€. -```shell -docker-compose -f ./docker/infra-compose.yml up +# Round-1 +### μ‹œμž‘ μ „ λͺ©ν‘œ +``` +- λ‚˜μ˜ μ˜λ„λ₯Ό ν…ŒμŠ€νŠΈ μ½”λ“œλ‘œ μž‘μ„±ν•œλ‹€. +- TDD λ°©μ‹μœΌλ‘œ AI와 ν•¨κ»˜ κΈ°λŠ₯ κ΅¬ν˜„ν•΄λ³Έλ‹€. +- TDD둜 μš”κ΅¬μ‚¬ν•­μ„ λ¨Όμ € μ •λ¦¬ν•˜λŠ” μž₯점을 λŠκ»΄λ³Έλ‹€. +- μž‘κ²Œ μͺΌκ°œκ³  μ μ§„μ μœΌλ‘œ μ„€κ³„ν•˜λŠ” 과정을 λŠκ»΄λ³Έλ‹€. +- λ¦¬νŒ©ν† λ§μ΄ κ°€λŠ₯ν•˜λ‹€λŠ”κ²ƒμ„ λŠκ»΄λ³Έλ‹€. ``` -### Monitoring -`local` ν™˜κ²½μ—μ„œ λͺ¨λ‹ˆν„°λ§μ„ ν•  수 μžˆλ„λ‘, `docker-compose` λ₯Ό 톡해 `prometheus` 와 `grafana` λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. -μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ‹€ν–‰ 이후, **http://localhost:3000** 둜 접속해, admin/admin κ³„μ •μœΌλ‘œ λ‘œκ·ΈμΈν•˜μ—¬ ν™•μΈν•˜μ‹€ 수 μžˆμŠ΅λ‹ˆλ‹€. -```shell -docker-compose -f ./docker/monitoring-compose.yml up +### μ‹œμž‘ ν›„ λͺ©ν‘œ +- μš”κ΅¬μ‚¬ν•­μ„ AI μ‹€ν–‰ ν”„λ‘¬ν”„νŠΈλ‘œ κ΅¬μ‘°ν™”ν•˜λŠ” 방법 μ•Œμ•„λ³΄κΈ° ``` +λ‚΄ ν˜„μž¬ ν•™μŠ΅ μ΄ˆμ μ€ 'μ„€κ³„λŠ” λ‚΄κ°€, κ΅¬ν˜„μ€ AIκ°€'λΌλŠ” λͺ…ν™•ν•œ μ—­ν•  뢄리닀. +TDD λ°©μ‹μœΌλ‘œ AIμ—κ²Œ 코딩을 μœ„μž„ν•˜λ©΄μ„œλ„ 섀계 κ²°μ •κΆŒμ€ λ‚΄κ°€ κ°€μ Έκ°€λŠ” 방식이닀. +μ—¬κΈ°μ„œ 질문이 μƒκ²ΌλŠ”λ°, κΈ°λŠ₯ μš”κ΅¬μ‚¬ν•­μ„ λ°›μ•˜μ„ λ•Œ, μ–΄λ–€ λ³€ν™˜ 과정을 거쳐 AIκ°€ μ •ν™•νžˆ κ΅¬ν˜„ν•  수 μžˆλŠ” ν”„λ‘¬ν”„νŠΈ ν˜•νƒœλ‘œ λ§Œλ“€μ–΄μ§€λŠ”κ°€?' -## About Multi-Module Project -λ³Έ ν”„λ‘œμ νŠΈλŠ” λ©€ν‹° λͺ¨λ“ˆ ν”„λ‘œμ νŠΈλ‘œ κ΅¬μ„±λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. 각 λͺ¨λ“ˆμ˜ μœ„κ³„ 및 역할을 λΆ„λͺ…νžˆ ν•˜κ³ , μ•„λž˜μ™€ 같은 κ·œμΉ™μ„ μ μš©ν•©λ‹ˆλ‹€. +이 λ³€ν™˜ 과정을 μ•Œμ•„λ³΄λŠ” 것을 λͺ©ν‘œλ‘œ ν–ˆλ‹€. +``` -- apps : 각 λͺ¨λ“ˆμ€ μ‹€ν–‰κ°€λŠ₯ν•œ **SpringBootApplication** 을 μ˜λ―Έν•©λ‹ˆλ‹€. -- modules : νŠΉμ • κ΅¬ν˜„μ΄λ‚˜ 도메인에 μ˜μ‘΄μ μ΄μ§€ μ•Šκ³ , reusable ν•œ configuration 을 μ›μΉ™μœΌλ‘œ ν•©λ‹ˆλ‹€. -- supports : logging, monitoring κ³Ό 같이 뢀가적인 κΈ°λŠ₯을 μ§€μ›ν•˜λŠ” add-on λͺ¨λ“ˆμž…λ‹ˆλ‹€. +### λ‚΄κ°€ ν•œ μ‹œλ„ -``` -Root -β”œβ”€β”€ apps ( spring-applications ) -β”‚ β”œβ”€β”€ πŸ“¦ commerce-api -β”‚ β”œβ”€β”€ πŸ“¦ commerce-batch -β”‚ └── πŸ“¦ commerce-streamer -β”œβ”€β”€ modules ( reusable-configurations ) -β”‚ β”œβ”€β”€ πŸ“¦ jpa -β”‚ β”œβ”€β”€ πŸ“¦ redis -β”‚ └── πŸ“¦ kafka -└── supports ( add-ons ) - β”œβ”€β”€ πŸ“¦ jackson - β”œβ”€β”€ πŸ“¦ monitoring - └── πŸ“¦ logging -``` +μ‹œλ„ 1: AIκ°€ μ„€κ³„ν•˜κ³ , AIκ°€ κ²°μ • +- 방식: κΈ°λŠ₯ μš”κ΅¬μ‚¬ν•­μ„ κ·ΈλŒ€λ‘œ 던져주고 TDD(Red-Green-Refactor)둜 κ°œλ°œν•˜λΌκ³  ν–ˆλ‹€. +- 문제점: 이 κ³Όμ •μ—μ„œ λ‚΄ 섀계도, λ‚΄ μ˜λ„λ„ μ—†λ‹€λŠ” κ±Έ μžκ°ν–ˆλ‹€. AIκ°€ μ•Œμ•„μ„œ λ‹€ ν–ˆμ„ λΏμ΄μ—ˆλ‹€. + +μ‹œλ„ 2: AIκ°€ μ„€κ³„ν•˜κ³ , λ‚΄κ°€ κ²°μ • +- 방식: AIκ°€ 섀계 λ³΄κ³ μ„œλ₯Ό μž‘μ„±ν•˜λ©΄ λ‚΄κ°€ 읽고 κ²°μ •ν•˜λŠ” μœ„μΉ˜μ— μ„œκΈ°λ‘œ ν–ˆλ‹€. +- 문제점: + - AIκ°€ λ„ˆλ¬΄ λ³΅μž‘ν•œ 섀계λ₯Ό μ œμ‹œν–ˆλ‹€. + - κ·Έ λ¬Έμ„œλ₯Ό μ½λŠ” 데 μ‹œκ°„μ΄ 많이 λ“€μ—ˆκ³ , μ „λΆ€ 읽을 μˆ˜λ„ μ—†μ—ˆλ‹€. + - λ‚΄ μ˜λ„κ°€ λ‹΄κΈ΄ 섀계라고 μ „ν˜€ λŠκ»΄μ§€μ§€ μ•Šμ•˜λ‹€. + +μ‹œλ„ 3: λ‚΄κ°€ μ„€κ³„ν•˜κ³ , AI의 ν”Όλ“œλ°±μ„ λ°›κΈ° +- 방식: κΈ°λŠ₯ μš”κ΅¬μ‚¬ν•­μœΌλ‘œλΆ€ν„° 객체와 λ©”μ‹œμ§€ μ •μ˜, 검증 및 μ˜ˆμ™Έ μΌ€μ΄μŠ€λ₯Ό λ‚΄κ°€ 직접 λ¬Έμ„œλ‘œ μ •λ¦¬ν–ˆλ‹€. +- μ’‹μ•˜λ˜ 점: + - 였직 λ‚΄ 섀계 ν‹€ μ•ˆμ—μ„œ AIκ°€ κ³ λ €ν•΄μ€˜μ„œ, μœ μš©ν•œ ν”Όλ“œλ°±μ„ λ°›μ•˜λ‹€. + - λ ˆμ΄μ–΄λ³„ νŠΉμ§•, ν•΄μ•Ό ν•  것, ν•˜μ§€ 말아야 ν•  것을 λ¨Όμ € μ •μ˜ν•΄μ•Ό ν•œλ‹€λŠ” κ±Έ λ°°μ› λ‹€. + - λ‚΄κ°€ λ†“μΉœ μ—£μ§€ μΌ€μ΄μŠ€λ‚˜ 잠재적 문제λ₯Ό 해상도 λ†’κ²Œ μ•Œλ €μ£Όκ³ , νŒλ‹¨μ„ μš”κ΅¬ν•΄μ€¬λ‹€. +- 문제점: + - νšŒμ›κ°€μž… κΈ°λŠ₯을 도메인뢀터 APIκΉŒμ§€ μ „λΆ€ μˆ˜λ„μ½”λ“œλ‘œ λ¬Έμ„œν™”ν•˜κ³  μžˆμ—ˆλŠ”λ°, μ΄λŸ¬λ‹€ λ³΄λ‹ˆ 생각이 λ“€μ—ˆλ‹€. + - "μˆ˜λ„μ½”λ“œλ‘œ λ¬Έμ„œν™”ν•  바에, κ·Έλƒ₯ λ‚΄κ°€ μ½”λ“œλ‘œ λ¨Όμ € μž‘μ„±ν•˜κ³  이 μŠ€νƒ€μΌλŒ€λ‘œ λ‚˜λ¨Έμ§€ κΈ°λŠ₯을 κ΅¬ν˜„ν•˜λΌκ³  ν•˜λŠ” 게 더 λ‚΄ μ˜λ„κ°€ λ‹΄κΈ΄ μ½”λ“œ μ•„λ‹Œκ°€?" + - μš”κ΅¬μ‚¬ν•­ κ·œμΉ™λΏλ§Œ μ•„λ‹ˆλΌ 개발 κ·œμΉ™λ“€μ„ λͺ…ν™•νžˆ ν•˜λŠ” 것이 λΆ€μ‘±ν–ˆλ‹€. + - λ‚΄ μ˜λ„λ₯Ό μ„€λͺ…ν•˜λ©΄μ„œ 개발 μŠ€νƒ€μΌμ„ μ•Œλ €μ€„ λ•Œ, μ§€κΈˆμ²˜λŸΌ λ¬Έμ„œλ‘œ μ „λ‹¬ν•˜λŠ” 게 λ§žλŠ”μ§€, μ•„λ‹ˆλ©΄ λ‚΄κ°€ κΈ°λŠ₯ κ΅¬ν˜„ ν•˜λ‚˜λ₯Ό μ˜ˆμ‹œλ‘œ λ§Œλ“€μ–΄μ£Όκ³  이 μŠ€νƒ€μΌμ„ μ°Έκ³ ν•΄μ„œ κ°œλ°œν•˜λΌκ³  ν•΄μ•Ό ν• μ§€ 고민이 λ“€μ—ˆλ‹€. + +μ‹œλ„ 4: λ‚΄κ°€ μ„€κ³„ν•˜κ³  μ‹œλ²” κ΅¬ν˜„μ„ μž‘μ„±ν•˜κ³ , AIμ—κ²Œ 이 λ°©μ‹λŒ€λ‘œ ν•˜λΌκ³  λͺ…λ Ή +- 방식: νšŒμ›κ°€μž… κΈ°λŠ₯을 TDD(Red-Green-Refactor)둜 λ„λ©”μΈμ—μ„œ APIκΉŒμ§€ 직접 κ΅¬ν˜„ν•˜κ³ , λ‚΄ μ½”λ“œ μŠ€νƒ€μΌλŒ€λ‘œ λ‹€λ₯Έ κΈ°λŠ₯ κ°œλ°œμ„ λ§‘κ²Όλ‹€. +- μ§„ν–‰ κ³Όμ •: + - AIλŠ” AλΆ€ν„° ZκΉŒμ§€ μ „λΆ€ λ§Œλ“€μ–΄λƒˆλ‹€. + - λ‚˜λŠ” μ™œ κ·Έλ ‡κ²Œ μ„€κ³„ν–ˆκ³  λ§Œλ“€μ—ˆλŠ”μ§€ λ¬Όμ–΄λ³΄λŠ” μ‹μœΌλ‘œ AI의 섀계 사고λ₯Ό 배우고 μžˆμ—ˆλ‹€. + - 이 λ‚΄μš©λ“€μ€ λ‚΄κ°€ 섀계λ₯Ό μ§„ν–‰ν–ˆμ–΄λ„ AIμ—κ²Œ λ¬Όμ–΄λ³Ό λ‚΄μš©λ“€μ΄μ—ˆλ‹€. + - λ‚΄ μ˜λ„κ°€ λ‹΄κΈ΄ μ½”λ“œλΌκΈ°λ³΄λ‹€λŠ”, λ‚˜λŠ” κ·Έ μ˜λ„λ₯Ό 이해해 λ‚˜κ°€κ³ , λ™μ˜ν•˜μ§€ μ•ŠμœΌλ©΄ λ‚΄ 섀계λ₯Ό λ‹΄μ•„λ‚΄λŠ” μ‹μœΌλ‘œ μ§„ν–‰ν–ˆλ‹€. +- 문제점: + - ν•œ 번 κ΅¬ν˜„ν•˜λΌκ³  ν•  λ•Œλ§ˆλ‹€ 였래 κ±Έλ Έλ‹€. μ²˜μŒλΆ€ν„° APIκΉŒμ§€ κ΄€λ ¨ λͺ¨λ“  μ½”λ“œλ₯Ό 20λΆ„ λ™μ•ˆ μž‘μ—…ν•˜κ³  μžˆμ—ˆλ‹€. + - μ „λΆ€ ν•œ 큐에 μ™„μ„±μ‹œν‚€κΈ° λ•Œλ¬Έμ— 양이 λ°©λŒ€ν–ˆλ‹€. + - λ‚˜λŠ” μΆ”κ°€λœ main μ½”λ“œ 확인과 ν…ŒμŠ€νŠΈ 톡과 μ—¬λΆ€λ§Œ ν™•μΈν•˜κ³  μžˆμ—ˆλ‹€. + - 이게 λ§žλŠ” λ°©μ‹μΈμ§€λŠ” 아직 잘 λͺ¨λ₯΄κ² λ‹€. + + + +### 과제 ν›„ λŠλ‚€μ  +- AI ν˜‘μ—…μ— λŒ€ν•œ λ―Έν•΄κ²° κ³ λ―Ό + - 아직 μ–΄λ–»κ²Œ AIλ₯Ό νŒŒνŠΈλ„ˆλ‘œ ν˜‘μ—…ν•˜λŠ” 것인지 κΉ¨λ‹«μ§€ λͺ»ν–ˆλ‹€. + - 이번 κ³Όμ œλŠ” 사싀 μ²˜μŒλΆ€ν„° μ™„λ²½ν•œ 섀계λ₯Ό λ§Œλ“€κ³  AIμ—κ²Œ κ°œλ°œν•˜λΌκ³  ν•˜λŠ” 게 μ•„λ‹ˆλΌ, κΈ°λŠ₯ μš”κ΅¬μ‚¬ν•­μœΌλ‘œλΆ€ν„° κ°œλ°œμžκ°€ 직접 TDDλ₯Ό κ΅¬ν˜„ν•˜λ©΄μ„œ 이 κ³Όμ •μ—μ„œ κΆκΈˆν•˜κ±°λ‚˜, λ§‰νžˆκ±°λ‚˜, λ…Έκ°€λ‹€ 과정을 AIμ—κ²Œ λ§‘κΈ°λŠ” 것을 κΈ°λŒ€ν•œ κ³Όμ œμ˜€λ˜ 걸까? +- ν΄λ‘œλ“œ μ½”λ“œ μ‚¬μš© κ²½ν—˜ + - μ΄λ²ˆμ— ν΄λ‘œλ“œ μ½”λ“œλ₯Ό μ‚¬μš©ν•΄μ„œ κ°œλ°œν•΄λ³΄λŠ” 건 처음인데, ν•œ 번의 λͺ…λ ΉμœΌλ‘œ AλΆ€ν„° ZκΉŒμ§€ κΈ°λŠ₯ κ΅¬ν˜„, λ¬Έμ„œν™”, ν…ŒμŠ€νŠΈ μ½”λ“œ μ „λΆ€ κ΅¬ν˜„ν•΄μ€˜μ„œ 놀라웠닀. + - 더 이상 κ΅¬ν˜„μ€ μ€‘μš”ν•˜μ§€ μ•Šλ‹€λŠ” 것을 μ΄λ²ˆμ— μ²΄κ°ν–ˆλ‹€. + - λŒ€μ‹  개발이 λ˜λŠ” ν™˜κ²½μ„ 잘 μ΄ν•΄ν•˜λŠ” 것, 계측 μ±…μž„μ΄λ‚˜ 객체 μ±…μž„ 및 검증 μŠ€μ½”ν”„λ₯Ό 잘 μ •μ˜ν•˜λŠ” 것, 이런 것듀이 더 μ€‘μš”ν•œ 것 같은 λŠλ‚Œμ„ λ°›μ•˜λ‹€. + - ν΄λ‘œλ“œ μŠ€ν‚¬μ— 관심이 생겼닀. + + + +--- + +## πŸ“‹ κΈ°λŠ₯ μš”κ΅¬ 사항 + +κΈ°λŠ₯ μš”κ΅¬ 사항 정리 [ToDoList.md](./ToDoList.md) diff --git a/ToDoList.md b/ToDoList.md new file mode 100644 index 00000000..3aa6eddf --- /dev/null +++ b/ToDoList.md @@ -0,0 +1,42 @@ +# κΈ°λŠ₯ μš”κ΅¬ 사항 + + +## 1. νšŒμ›κ°€μž… + +### μš”κ΅¬μ‚¬ν•­ +- **ν•„μš” 정보 : { 둜그인 ID, λΉ„λ°€λ²ˆν˜Έ, 이름, 생년월일, 이메일 }** +- 이미 κ°€μž…λœ 둜그인 ID λ‘œλŠ” κ°€μž…μ΄ λΆˆκ°€λŠ₯함 +- 각 μ •λ³΄λŠ” 포맷에 λ§žλŠ” 검증 ν•„μš” (이름, 이메일, 생년월일) +- λΉ„λ°€λ²ˆν˜ΈλŠ” μ•”ν˜Έν™”ν•΄ μ €μž₯ν•˜λ©°, μ•„λž˜μ™€ 같은 κ·œμΉ™μ„ 따름 +``` +1. 8~16자의 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자만 κ°€λŠ₯ν•©λ‹ˆλ‹€. +2. 생년월일은 λΉ„λ°€λ²ˆν˜Έ 내에 포함될 수 μ—†μŠ΅λ‹ˆλ‹€. +``` +> 이후, μœ μ € 정보가 ν•„μš”ν•œ λͺ¨λ“  μš”μ²­μ€ μ•„λž˜ 헀더λ₯Ό 톡해 μš”μ²­ +> * X-Loopers-LoginId : 둜그인 ID +> * X-Loopers-LoginPw : λΉ„λ°€λ²ˆν˜Έ + + +--- + +## 2. λ‚΄ 정보 쑰회 +- **λ°˜ν™˜ 정보 : { 둜그인 ID, 이름, 생년월일, 이메일 }** +- 둜그인 ID λŠ” 영문과 숫자만 ν—ˆμš© +- 이름은 λ§ˆμ§€λ§‰ κΈ€μžλ₯Ό λ§ˆμŠ€ν‚Ήν•΄ λ°˜ν™˜ + +> λ§ˆμŠ€ν‚Ή λ¬ΈμžλŠ” `*` 둜 톡일 +> + +--- + +## 3. λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • +- **ν•„μš” 정보 : { κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έ, μƒˆ λΉ„λ°€λ²ˆν˜Έ }** +- λΉ„λ°€ 번호 RULE 을 λ”°λ₯΄λ˜, ν˜„μž¬ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€. + +> **λΉ„λ°€λ²ˆν˜Έ RULE** +> * 영문 λŒ€/μ†Œλ¬Έμž, 숫자, 특수문자 μ‚¬μš© κ°€λŠ₯ +> * 생년월일 μ‚¬μš© λΆˆκ°€ + + +--- + 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 new file mode 100644 index 00000000..bf148ae1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java @@ -0,0 +1,28 @@ +package com.loopers.domain; + +import com.loopers.infrastructure.PasswordEncoder; +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; + private final PasswordEncoder passwordEncoder; + + public UserModel authenticate(String loginIdValue, String rawPassword) { + LoginId loginId = new LoginId(loginIdValue); + + UserModel user = userRepository.find(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "둜그인 ID λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + + if (!passwordEncoder.matches(rawPassword, user.getPassword().getValue())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "둜그인 ID λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + return user; + } +} 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..8dcb741c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java @@ -0,0 +1,41 @@ +package com.loopers.domain; + +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 LocalDate birthDate; + + protected 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(DATE_STRING_FORMATTER); + } + + 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..767b156b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Email.java @@ -0,0 +1,34 @@ +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..98e718ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java @@ -0,0 +1,41 @@ +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; + +@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 String value; + + protected LoginId() {} + + 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/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..e7495e8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -0,0 +1,35 @@ +package com.loopers.domain; + +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 String name; + + protected 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, "μœ νš¨ν•˜μ§€ μ•Šμ€ 이름 κΈΈμ΄μž…λ‹ˆλ‹€."); + } + } + + public String getMaskedName() { + if (name == null || name.isEmpty()) return name; + return name.substring(0, name.length() - 1) + "*"; + } +} 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..17d4ec1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -0,0 +1,56 @@ +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; + +@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 String value; + + protected Password() {} + + public Password(String value) { + validate(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자의 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자 쑰합이어야 ν•©λ‹ˆλ‹€."); + } + } + + public void validateNotContainBirthday(BirthDate birthDate) { + String birthDateString = birthDate.toDateString(); + + 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/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..4c76f23d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -0,0 +1,67 @@ +package com.loopers.domain; + +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.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserModel extends BaseEntity { + + @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) { + 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, "이메일"); + } + private void validateNotNull(Object value, String fieldName) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST,fieldName + "은(λŠ”) ν•„μˆ˜ μž…λ ₯κ°’μž…λ‹ˆλ‹€."); + } + } + + public void changePassword(Password currentPassword, Password newPassword) { + // 검증은 UserServiceμ—μ„œ μˆ˜ν–‰ + this.password = newPassword; + } +} 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..874c1f87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -0,0 +1,62 @@ +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; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@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) { + + if(userRepository.find(loginId).isPresent()) { + throw new CoreException(ErrorType.BAD_REQUEST,"이미 μ‘΄μž¬ν•˜λŠ” μ•„μ΄λ””μž…λ‹ˆλ‹€."); + } + + 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); + } + + 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, "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + + // ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 검증 + 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/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/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/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..b3d0743c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,44 @@ +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.Parameter; +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 + ); + + @Operation( + summary = "λ‚΄ 정보 쑰회", + description = "인증된 μ‚¬μš©μžμ˜ 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€. 헀더에 X-Loopers-LoginId와 X-Loopers-LoginPwλ₯Ό 포함해야 ν•©λ‹ˆλ‹€." + ) + ApiResponse getMyInfo( + @Parameter(description = "둜그인 ID", required = true) + String loginId, + @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 new file mode 100644 index 00000000..2f715515 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,70 @@ +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.*; + +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 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 + 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); + } + + @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); + } + + @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 new file mode 100644 index 00000000..7daae480 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,92 @@ +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() + ); + } + } + + 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() + ); + } + } + + 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("λΉ„λ°€λ²ˆν˜Έκ°€ μ„±κ³΅μ μœΌλ‘œ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + } +} 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(), "이미 μ‘΄μž¬ν•˜λŠ” λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€."); 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..1cf99940 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -0,0 +1,114 @@ +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; + + @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), + Password.fromEncoded(encodedPassword), + 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)); + when(passwordEncoder.matches(validPassword, encodedPassword)).thenReturn(true); + + // 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!@#"; + when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); + + // act & assert + assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, wrongPassword)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasMessageContaining("둜그인 ID λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + @Test + @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.UNAUTHORIZED); + } + } +} 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"); + } + } +} 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("이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } +} 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); + } + } +} 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..eeb4796e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java @@ -0,0 +1,86 @@ +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.CsvSource; +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);} + } + + @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); + } + } +} 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 new file mode 100644 index 00000000..6082a9ef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -0,0 +1,86 @@ +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); + } + } +} 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..67067309 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -0,0 +1,221 @@ +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.PasswordEncoder; +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; +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 PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private LoginId validLoginId; + private Password validPassword; + private String rawPassword; + private Name validName; + private BirthDate validBirthDate; + private Email validEmail; + + @BeforeEach + void setUp() { + validLoginId = new LoginId("testuser123"); + rawPassword = "Test1234!@#"; + validPassword = new Password(rawPassword); + 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.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("μœ μ €κ°€ λ‚΄ 정보λ₯Ό μ‘°νšŒν•  λ•Œ") + @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("μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + @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); + String savedPassword = updatedUser.getPassword().getValue(); + assertAll( + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 이전 평문과 닀름 + () -> assertThat(savedPassword).isNotEqualTo(newPassword.getValue()), // μƒˆ 평문과도 닀름 (μ•”ν˜Έν™”λ¨) + () -> assertThat(passwordEncoder.matches(newPassword.getValue(), savedPassword)).isTrue() // μƒˆ λΉ„λ°€λ²ˆν˜Έμ™€ 맀칭됨 + ); + } + + @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 new file mode 100644 index 00000000..c81b0f5b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,679 @@ +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) + ); + } + } + + @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()); + } + } + + @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) + ); + } + } +} 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 new file mode 100644 index 00000000..726e819a --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,265 @@ +### νšŒμ›κ°€μž… - 성곡 μΌ€μ΄μŠ€ +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" +} + +############################################### +# λ‚΄ 정보 쑰회 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 + +############################################### +# λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ 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" +} + +############################################### +# μ‹œλ‚˜λ¦¬μ˜€ ν…ŒμŠ€νŠΈ (전체 ν”Œλ‘œμš°) +############################################### + +### [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!@#