From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 1/8] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. From 49e1d7232d6a4c62c38deb79e59d4e2176100850 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 01:01:34 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 31 ++++++ .../application/member/MemberInfo.java | 15 +++ .../com/loopers/config/SecurityConfig.java | 15 +++ .../loopers/domain/member/MemberModel.java | 99 +++++++++++++++++++ .../domain/member/MemberRepository.java | 9 ++ .../loopers/domain/member/MemberService.java | 39 ++++++++ .../member/MemberJpaRepository.java | 11 +++ .../member/MemberRepositoryImpl.java | 29 ++++++ .../api/member/MemberV1ApiSpec.java | 23 +++++ .../api/member/MemberV1Controller.java | 36 +++++++ .../interfaces/api/member/MemberV1Dto.java | 25 +++++ .../member/MemberServiceIntegrationTest.java | 91 +++++++++++++++++ build.gradle.kts | 49 +++++---- docker/infra-compose.yml | 2 +- modules/jpa/src/main/resources/jpa.yml | 2 +- 15 files changed, 455 insertions(+), 21 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..335399d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,31 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.member.MemberV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { + // 1. Request โ†’ MemberModel๋กœ ๋ณ€ํ™˜ + MemberModel memberModel = new MemberModel( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + // 2. Service ํ˜ธ์ถœ (์ €์žฅ + ์ค‘๋ณต ์ฒดํฌ) + MemberModel saved = memberService.saveMember(memberModel); + + // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ + return MemberInfo.from(saved); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 00000000..2ae39624 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +// MemberInfo๋Š” Facade โ†’ Controller๋กœ ์ „๋‹ฌ๋˜๋Š” ๋ฐ์ดํ„ฐ +public record MemberInfo(String loginId, String name, String birthDate, String email) { + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.getLoginId(), + model.getName(), + model.getBirthDate(), + model.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..7e23f737 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 00000000..4ba20abd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,99 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + private String loginId; + private String password ; + private String name; + private String birthDate; + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, String birthDate, String email) { + + // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ + // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + private void validatePassword(String password, String birthDate) { + // 1. 8~16์ž ๊ธธ์ด ์ฒดํฌ + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, + "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // 2. ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ํ—ˆ์šฉ (ํ•œ๊ธ€, ๊ณต๋ฐฑ ๋“ฑ ๋ถˆ๊ฐ€) + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, + "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); + } + + // 3. ์ƒ๋…„์›”์ผ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ์— ํฌํ•จ๋˜๋ฉด ์•ˆ๋จ + if (password.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์„ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + // ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์—”ํ‹ฐํ‹ฐ์— ๋„ฃ์–ด์ฃผ๊ธฐ + public void encryptPassword(String encryptedPassword) { + this.password = encryptedPassword; + } + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 00000000..1d703f71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + MemberModel save(MemberModel memberModel); + Optional update(MemberModel memberModel); + Optional findByLoginId(String id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 00000000..ec62150a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,39 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = false) + public MemberModel saveMember(MemberModel memberModel) { + //์ €์žฅํ•˜๊ธฐ ์ „์— ์ด๋ฏธ ๊ฐ™์€ loginId๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); + if (existing.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } + + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ + String encrypted = passwordEncoder.encode(memberModel.getPassword()); + memberModel.encryptPassword(encrypted); + memberRepository.save(memberModel); + + return getMember(memberModel.getLoginId()); + } + + @Transactional(readOnly = true) + public MemberModel getMember(String id) { + return memberRepository.findByLoginId(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..0bcc2c78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + MemberModel save(MemberModel memberModel); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 00000000..17e86c5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel memberModel) { + return memberJpaRepository.save(memberModel); + } + + @Override + public Optional update(MemberModel memberModel) { + return Optional.empty(); + } + + @Override + public Optional findByLoginId(String id) { + return memberJpaRepository.findByLoginId(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 00000000..c312ab9f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "Member V1 API", description = "ํšŒ์› API") +public interface MemberV1ApiSpec { + + @Operation( + summary = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", + description = "์ฃผ์–ด์ง„ ์ •๋ณด๋ฅผ ๊ฐ€์ง€๊ณ  ํšŒ์› ๊ฐ€์ž…์„ ์‹คํ–‰ํ•œ๋‹ค" + ) + // @Schema๋Š” Swagger API ๋ฌธ์„œ์—์„œ ํŒŒ๋ผ๋ฏธํ„ฐ ์„ค๋ช…์„ ๋ณด์—ฌ์ฃผ๋Š” ์šฉ๋„ + // ์˜ˆ์ œ์—์„œ๋Š” Long exampleId ๊ฐ™์€ ๋‹จ์ผ ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋ถ™์˜€๋Š”๋ฐ, ์ง€๊ธˆ์€ SignUpRequest๋กœ ํ†ต์งธ๋กœ ๋ฐ›์œผ๋‹ˆ๊นŒ ์—ฌ๊ธฐ์—” ํ•„์š” ์—†์Œ + ApiResponse signUp( + MemberV1Dto.SignUpRequest request + ); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 00000000..744937a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + // ํด๋ผ์ด์–ธํŠธ โ†’ [SignUpRequest (record)] โ†’ Controller โ†’ Facade โ†’ Service โ†’ [MemberModel (entity)] โ†’ DB + // ์š”์ฒญ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ์šฉ DB์— ์ €์žฅ๋˜๋Š” ๊ฐ์ฒด + // DB โ†’ [MemberModel (entity)] โ†’ Facade โ†’ [SignUpResponse (record)] โ†’ Controller โ†’ ํด๋ผ์ด์–ธํŠธ + // DB์—์„œ ๊บผ๋‚ธ ๊ฐ์ฒด ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ „๋‹ฌ์šฉ + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + MemberInfo info = memberFacade.signupMember(request); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..e1545299 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.member; + + +import com.loopers.application.member.MemberInfo; + +public class MemberV1Dto { + + // Request: POST๋ฐฉ์‹์œผ๋กœ ๋ณด๋‚ผ๋•Œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๋Š” ๊ทธ๋ฆ‡ (from ํ•„์š” ์—†์Œ) + public record SignUpRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + // Response: ๋ณ€ํ™˜ ๋ฉ”์„œ๋“œ(from)๊ฐ€ ์—ฌ๊ธฐ์—! + public record SignUpResponse(String loginId) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse(info.loginId()); + } + } + + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java new file mode 100644 index 00000000..7cb23df8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›๊ฐ€์ž…์„ ์„ฑ๊ณตํ•œ๋‹ค") + @Nested + class SaveMember { + + @DisplayName("ํšŒ์›๊ฐ€์ž…์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ๋“ค์–ด์˜ค๋ฉด ๋””๋น„์— ์ €์žฅํ•˜๊ณ  ์ €์žฅํ•œ ์•„์ด๋””๋ฅผ ์กฐํšŒํ•œ๋‹ค") + @Test + void returnsMemberInfo_whenValidMemberInfoIsProvided() { + // arrange + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + + // act + memberService.saveMember(memberModel); + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), + () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), + () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), + () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) + ); + } + + @DisplayName("์ค‘๋ณต ID๋กœ ๊ฐ€์ž… ์‹œ๋„ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenExistIdIsTryToSaveMember() { + // arrange - ๋จผ์ € ํ•œ ๋ช… ๊ฐ€์ž…์‹œํ‚ค๊ธฐ + memberService.saveMember(new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + + // act - ๊ฐ™์€ ID๋กœ ๋˜ ๊ฐ€์ž… ์‹œ๋„ + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.saveMember(new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com")); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + } + + @DisplayName("ํšŒ์›์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetMember { + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.getMember(loginId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8..6f9ce28b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,10 +34,16 @@ allprojects { } subprojects { + val containerProjects = setOf("apps", "modules", "supports") + apply(plugin = "java") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - apply(plugin = "jacoco") + + // TODO: JDK 24 ํ™˜๊ฒฝ์—์„œ JacocoReport ํƒœ์Šคํฌ ์ƒ์„ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ - JDK 21๋กœ ์ „ํ™˜ ํ›„ ํ™œ์„ฑํ™” ํ•„์š” + // if (name !in containerProjects) { + // apply(plugin = "jacoco") + // } dependencyManagement { imports { @@ -55,6 +61,8 @@ subprojects { // Lombok implementation("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // ์•”ํ˜ธํ™” + implementation ("org.springframework.security:spring-security-crypto") // Test testRuntimeOnly("org.junit.platform:junit-platform-launcher") // testcontainers:mysql ์ด jdbc ์‚ฌ์šฉํ•จ @@ -85,24 +93,27 @@ subprojects { jvmArgs("-Xshare:off") } - tasks.withType { - mustRunAfter("test") - executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) - reports { - xml.required = true - csv.required = false - html.required = false - } - afterEvaluate { - classDirectories.setFrom( - files( - classDirectories.files.map { - fileTree(it) - }, - ), - ) - } - } + // TODO: JDK 24 ํ™˜๊ฒฝ์—์„œ JacocoReport ํƒœ์Šคํฌ ์ƒ์„ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ - JDK 21๋กœ ์ „ํ™˜ ํ›„ ํ™œ์„ฑํ™” ํ•„์š” + // if (name !in containerProjects) { + // tasks.withType { + // mustRunAfter("test") + // executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) + // reports { + // xml.required = true + // csv.required = false + // html.required = false + // } + // afterEvaluate { + // classDirectories.setFrom( + // files( + // classDirectories.files.map { + // fileTree(it) + // }, + // ), + // ) + // } + // } + // } } // module-container ๋Š” task ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋„๋ก ํ•œ๋‹ค. diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5..6921cb39 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -3,7 +3,7 @@ services: mysql: image: mysql:8.0 ports: - - "3306:3306" + - "3307:3306" environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_USER=application diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b..8b90b1c7 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -42,7 +42,7 @@ spring: datasource: mysql-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:mysql://localhost:3307/loopers username: application password: application From d4a67a77a40a4e51ffef11e0bed1ee9d90c4ade8 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 01:02:14 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 166 ++++++++++++++++++ .../domain/member/MemberModelTest.java | 118 +++++++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 100 +++++++++++ 3 files changed, 384 insertions(+) create mode 100644 CLAUDE.md create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e57d39ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Tech Stack & Versions + +| Category | Technology | Version | +|----------|------------|---------| +| Language | Java | 21 | +| Framework | Spring Boot | 3.4.4 | +| Dependency Management | Spring Dependency Management | 1.1.7 | +| Cloud | Spring Cloud | 2024.0.1 | +| Build Tool | Gradle (Kotlin DSL) | 8.13+ | +| API Documentation | SpringDoc OpenAPI | 2.7.0 | +| ORM | Spring Data JPA + QueryDSL | (managed by Spring Boot) | +| Database | MySQL | 8.0 | +| Cache | Redis (Master-Replica) | - | +| Messaging | Kafka | 3.5.1 | +| Monitoring | Micrometer + Prometheus | (managed by Spring Boot) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockk 4.0.2, Instancio 5.0.2 | - | +| Containers | TestContainers | (managed by Spring Boot) | + +## Build & Run Commands + +```bash +# Build all modules +./gradlew build + +# Run tests (profile: test, timezone: Asia/Seoul) +./gradlew test + +# Run specific app +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=jobName' +./gradlew :apps:commerce-streamer:bootRun + +# Build specific module +./gradlew :apps:commerce-api:build + +# Run single test class +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest" + +# Run single test method +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest.testMethodName" + +# Test with coverage report +./gradlew test jacocoTestReport +``` + +**Java version**: 21 (configured via Gradle toolchain) + +## Local Infrastructure + +```bash +# Start MySQL, Redis (master+replica), Kafka +docker-compose -f docker/infra-compose.yml up + +# Start Prometheus + Grafana monitoring +docker-compose -f docker/monitoring-compose.yml up +``` + +- MySQL: localhost:3307 (root/root, application/application) +- Redis Master: localhost:6379, Replica: localhost:6380 +- Kafka: localhost:19092, Kafka UI: localhost:9099 +- Grafana: localhost:3000 (admin/admin) + +## Architecture + +### Multi-Module Structure + +``` +loopers-java-spring-template/ +โ”œโ”€โ”€ apps/ # Executable Spring Boot applications +โ”‚ โ”œโ”€โ”€ commerce-api # REST API (web, actuator, springdoc-openapi) +โ”‚ โ”œโ”€โ”€ commerce-batch # Batch jobs (spring-batch) +โ”‚ โ””โ”€โ”€ commerce-streamer # Event streaming (web, kafka) +โ”œโ”€โ”€ modules/ # Reusable infrastructure configurations +โ”‚ โ”œโ”€โ”€ jpa # JPA, QueryDSL, MySQL connector +โ”‚ โ”œโ”€โ”€ redis # Spring Data Redis (master-replica) +โ”‚ โ””โ”€โ”€ kafka # Spring Kafka +โ””โ”€โ”€ supports/ # Cross-cutting add-on modules + โ”œโ”€โ”€ jackson # Jackson serialization (Kotlin module, JSR310) + โ”œโ”€โ”€ logging # Logback, Slack appender + โ””โ”€โ”€ monitoring # Micrometer, Prometheus registry +``` + +### Module Dependencies + +| App | modules | supports | +|-----|---------|----------| +| commerce-api | jpa, redis | jackson, logging, monitoring | +| commerce-batch | jpa, redis | jackson, logging, monitoring | +| commerce-streamer | jpa, redis, kafka | jackson, logging, monitoring | + +### Layer Architecture (commerce-api) +``` +interfaces/api/ โ†’ Controllers, DTOs, OpenAPI specs +application/ โ†’ Facades (use case orchestration) +domain/ โ†’ Entities, Services, Repository interfaces +infrastructure/ โ†’ Repository implementations, adapters +``` + +### Key Patterns +- **Controllers**: Implement `*ApiSpec` interfaces for OpenAPI documentation +- **Facades**: Orchestrate domain services, convert domain models to DTOs +- **Services**: `@Component` with `@Transactional`, contain business logic +- **Repositories**: Interface in `domain/`, implementation in `infrastructure/` +- **Entities**: Extend `BaseEntity` (provides id, createdAt, updatedAt, deletedAt) +- **Response wrapper**: All APIs return `ApiResponse` +- **Error handling**: `CoreException` with `ErrorType` enum, caught by `ApiControllerAdvice` + +### Soft Delete +Entities use `deletedAt` field via `BaseEntity`: +```java +entity.delete(); // marks as deleted +entity.restore(); // restores +``` + +## Configuration + +- Profile-based: local, test, dev, qa, prd +- Config imports in application.yml: jpa.yml, redis.yml, logging.yml, monitoring.yml +- Management endpoints on port 8081 (/health, /prometheus) + +## Testing + +- Framework: JUnit 5 + AssertJ + Mockito + SpringMockk + Instancio +- `DatabaseCleanUp` utility truncates tables between tests (from jpa test fixtures) +- `RedisCleanUp` available from redis test fixtures +- TestContainers support for MySQL, Redis, Kafka + +## ๊ฐœ๋ฐœ ๊ทœ์น™ + +### ์ง„ํ–‰ Workflow - ์ฆ๊ฐ• ์ฝ”๋”ฉ +- **๋Œ€์›์น™**: ๋ฐฉํ–ฅ์„ฑ ๋ฐ ์ฃผ์š” ์˜์‚ฌ ๊ฒฐ์ •์€ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์ œ์•ˆ๋งŒ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ตœ์ข… ์Šน์ธ๋œ ์‚ฌํ•ญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘์—… ์ˆ˜ํ–‰ +- **์ค‘๊ฐ„ ๊ฒฐ๊ณผ ๋ณด๊ณ **: AI๊ฐ€ ๋ฐ˜๋ณต์ ์ธ ๋™์ž‘์„ ํ•˜๊ฑฐ๋‚˜, ์š”์ฒญํ•˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„, ํ…Œ์ŠคํŠธ ์‚ญ์ œ๋ฅผ ์ž„์˜๋กœ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ๊ฐœ์ž… +- **์„ค๊ณ„ ์ฃผ๋„๊ถŒ ์œ ์ง€**: AI๊ฐ€ ์ž„์˜ํŒ๋‹จ์„ ํ•˜์ง€ ์•Š๊ณ , ๋ฐฉํ–ฅ์„ฑ์— ๋Œ€ํ•œ ์ œ์•ˆ ๋“ฑ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜ ๊ฐœ๋ฐœ์ž์˜ ์Šน์ธ์„ ๋ฐ›์€ ํ›„ ์ˆ˜ํ–‰ + +### ๊ฐœ๋ฐœ Workflow - TDD (Red โ†’ Green โ†’ Refactor) +- ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋Š” 3A ์›์น™์œผ๋กœ ์ž‘์„ฑ (Arrange - Act - Assert) + +| Phase | ์„ค๋ช… | +|-------|------| +| **Red** | ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜๋Š” ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ๋จผ์ € ์ž‘์„ฑ | +| **Green** | Red Phase์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํ†ต๊ณผํ•  ์ˆ˜ ์žˆ๋Š” ์ตœ์†Œํ•œ์˜ ์ฝ”๋“œ ์ž‘์„ฑ (์˜ค๋ฒ„์—”์ง€๋‹ˆ์–ด๋ง ๊ธˆ์ง€) | +| **Refactor** | ๋ถˆํ•„์š”ํ•œ private ํ•จ์ˆ˜ ์ง€์–‘, ๊ฐ์ฒด์ง€ํ–ฅ์  ์ฝ”๋“œ ์ž‘์„ฑ, unused import ์ œ๊ฑฐ, ์„ฑ๋Šฅ ์ตœ์ ํ™”. ๋ชจ๋“  ํ…Œ์ŠคํŠธ ํ†ต๊ณผ ํ•„์ˆ˜ | + +### ์ฃผ์˜์‚ฌํ•ญ + +**Never Do:** +- ์‹ค์ œ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š” ์ฝ”๋“œ, ๋ถˆํ•„์š”ํ•œ Mock ๋ฐ์ดํ„ฐ๋ฅผ ์ด์šฉํ•œ ๊ตฌํ˜„ ๊ธˆ์ง€ +- null-safety ํ•˜์ง€ ์•Š์€ ์ฝ”๋“œ ์ž‘์„ฑ ๊ธˆ์ง€ (Java์˜ ๊ฒฝ์šฐ Optional ํ™œ์šฉ) +- println ์ฝ”๋“œ ๋‚จ๊ธฐ์ง€ ๋ง ๊ฒƒ + +**Recommendation:** +- ์‹ค์ œ API๋ฅผ ํ˜ธ์ถœํ•ด ํ™•์ธํ•˜๋Š” E2E ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด ์„ค๊ณ„ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ๋Œ€์•ˆ ๋ฐ ์ œ์•ˆ +- ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ API๋Š” `http/*.http` ํŒŒ์ผ์— ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑ + +**Priority:** +1. ์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…๋งŒ ๊ณ ๋ ค +2. null-safety, thread-safety ๊ณ ๋ ค +3. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ +4. ๊ธฐ์กด ์ฝ”๋“œ ํŒจํ„ด ๋ถ„์„ ํ›„ ์ผ๊ด€์„ฑ ์œ ์ง€ diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 00000000..0d0cfd1f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,118 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberModelTest { + + @DisplayName("ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("(์„ฑ๊ณต์ผ€์ด์Šค) ํ•„์ˆ˜ ์ •๋ณด๊ฐ€ ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsMemberModel_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(member.getLoginId()).isEqualTo(loginId); + assertThat(member.getPassword()).isNotEqualTo(rawPassword); + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirthDate()).isEqualTo(birthDate); + assertThat(member.getEmail()).isEqualTo(email); + // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋˜๋ฏ€๋กœ ์›๋ณธ๊ณผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ - ๋‚˜์ค‘์— ๊ฒ€์ฆ ๋ฐฉ์‹ ๊ฒฐ์ • + } + + + @DisplayName("(์‹คํŒจ์ผ€์ด์Šค) ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 7์ž์ผ๋•Œ, ์˜ˆ์™ธ๋ฐœ์ƒ.") + @Test + void throwsBadRequestException_whenPwIsOutOfRange() { + // arrange + String loginId = "testuser"; + String password = "Test12!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 17์ž์ผ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsOutOfRange2() { + // arrange + String loginId = "testuser"; + String password = "Test123456789012!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ํ•œ๊ธ€์ด ์žˆ์„ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsKorean() { + // arrange + String loginId = "testuser"; + String password = "Testํ™๊ธธ๋™890123!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋  ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwContainsBirthDate() { + // arrange + String loginId = "testuser"; + String password = "Test19900101!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..8e2f27f1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,100 @@ +package com.loopers.interfaces.api; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (ํšŒ์›๊ฐ€์ž…)") + @Nested + class SignUp { + + @DisplayName("์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ๋ณด๋‚ด๋ฉด, 201 Created์™€ ์ƒ์„ฑ๋œ ID๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCreated_whenValidMemberInfoIsProvided() { + // arrange + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@example.com" + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().get("loginId")).isNotNull() + ); + } + + @DisplayName("์ค‘๋ณต๋œ loginId๋กœ ๊ฐ€์ž…ํ•˜๋ฉด, 409 Conflict๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsConflict_whenDuplicateLoginIdIsProvided() { + // arrange - ๋จผ์ € ํ•œ ๋ช… ๊ฐ€์ž… + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference>>() {}); + + // act - ๊ฐ™์€ ID๋กœ ๋‹ค์‹œ ๊ฐ€์ž… + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } +} From 5860d3398d0f1859732dd116d490ef12e0d1e996 Mon Sep 17 00:00:00 2001 From: ksonepick-dev Date: Thu, 5 Feb 2026 18:57:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 11 +++-- .../application/member/MemberInfo.java | 2 +- .../loopers/domain/member/MemberModel.java | 46 +++++++++++++++++-- .../domain/member/MemberRepository.java | 2 +- .../loopers/domain/member/MemberService.java | 7 +-- .../api/member/MemberV1ApiSpec.java | 4 ++ .../api/member/MemberV1Controller.java | 26 +++++++---- .../domain/member/MemberModelTest.java | 2 +- .../member/MemberServiceIntegrationTest.java | 30 ++++++++++-- 9 files changed, 103 insertions(+), 27 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 335399d2..12272ab3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -25,7 +25,12 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { // 2. Service ํ˜ธ์ถœ (์ €์žฅ + ์ค‘๋ณต ์ฒดํฌ) MemberModel saved = memberService.saveMember(memberModel); - // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ - return MemberInfo.from(saved); - } + // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ + return MemberInfo.from(saved); + } + + public MemberInfo getMyInfo(String loginId) { + MemberModel member = memberService.getMember(loginId); + return MemberInfo.from(member); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java index 2ae39624..38b99890 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -7,7 +7,7 @@ public record MemberInfo(String loginId, String name, String birthDate, String e public static MemberInfo from(MemberModel model) { return new MemberInfo( model.getLoginId(), - model.getName(), + model.getMaskedName(), model.getBirthDate(), model.getEmail() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 4ba20abd..bb3b422f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -37,11 +37,14 @@ public MemberModel(String loginId, String password, String name, String birthDat throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } - // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ - // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. - // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. - // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ - validatePassword(password, birthDate); + // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + + // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + validatePassword(password, birthDate); this.loginId = loginId; this.password = password; @@ -50,6 +53,11 @@ public MemberModel(String loginId, String password, String name, String birthDat this.email = email; } + public MemberModel(String loginId) { + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + } + public String getLoginId() { return loginId; } @@ -70,6 +78,16 @@ public String getEmail() { return email; } + private void validateLoginId(String loginId) { + // ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException( + ErrorType.BAD_REQUEST, + "๋กœ๊ทธ์ธ ์•„์ด๋””๋Š” ์˜๋ฌธ์ž์™€ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + ); + } + } + private void validatePassword(String password, String birthDate) { // 1. 8~16์ž ๊ธธ์ด ์ฒดํฌ if (password.length() < 8 || password.length() > 16) { @@ -95,5 +113,23 @@ public void encryptPassword(String encryptedPassword) { this.password = encryptedPassword; } + // ์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž์— ๋งˆ์Šคํ‚น ์ถ”๊ฐ€ + public String maskLastChar(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("์ด๋ฆ„์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (name.length() == 1) { + return "*"; + } + + return name.substring(0, name.length() - 1) + "*"; + } + + // ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„ ๊ฐ€์ ธ์˜ค๊ธฐ + public String getMaskedName(){ + return maskLastChar(this.name); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index 1d703f71..b86afc71 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -4,6 +4,6 @@ public interface MemberRepository { MemberModel save(MemberModel memberModel); - Optional update(MemberModel memberModel); Optional findByLoginId(String id); + Optional update(MemberModel memberModel); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index ec62150a..bb6cca4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -32,8 +32,9 @@ public MemberModel saveMember(MemberModel memberModel) { } @Transactional(readOnly = true) - public MemberModel getMember(String id) { - return memberRepository.findByLoginId(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + public MemberModel getMember(String loginId) { + MemberModel model = new MemberModel(loginId); // ๊ฐ์ฒด ๋จผ์ € ์ƒ์„ฑํ•ด์•ผ ํ•จ + return memberRepository.findByLoginId(model.getLoginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index c312ab9f..c58dffe2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -20,4 +20,8 @@ ApiResponse signUp( MemberV1Dto.SignUpRequest request ); + ApiResponse getMyInfo( + String loginId + ); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 744937a7..6ca5f25c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -24,13 +24,23 @@ public class MemberV1Controller implements MemberV1ApiSpec { // DB์—์„œ ๊บผ๋‚ธ ๊ฐ์ฒด ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ „๋‹ฌ์šฉ private final MemberFacade memberFacade; - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse signUp(@RequestBody SignUpRequest request) { - MemberInfo info = memberFacade.signupMember(request); - MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); - return ApiResponse.success(response); - } + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + MemberInfo info = memberFacade.signupMember(request); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{loginId}") + @Override + public ApiResponse getMyInfo( + @PathVariable(value = "loginId") String loginId + ) { + MemberInfo info = memberFacade.getMyInfo(loginId); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index 0d0cfd1f..b827bbdd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -30,7 +30,7 @@ void createsMemberModel_whenAllFieldsAreProvided() { // assert assertThat(member.getLoginId()).isEqualTo(loginId); - assertThat(member.getPassword()).isNotEqualTo(rawPassword); + assertThat(member.getPassword()).isEqualTo(rawPassword); assertThat(member.getName()).isEqualTo(name); assertThat(member.getBirthDate()).isEqualTo(birthDate); assertThat(member.getEmail()).isEqualTo(email); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 7cb23df8..d7b296cb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -73,11 +73,31 @@ void throwsException_whenExistIdIsTryToSaveMember() { @Nested class GetMember { - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - @Test - void throwsException_whenMemberNotFound() { - // arrange - String loginId = "testuser"; // Assuming this ID does not exist + @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange // ์ •๋ณด์ €์žฅ + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + memberService.saveMember(memberModel); + + // act + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), + () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), + () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), + () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist // act CoreException exception = assertThrows(CoreException.class, () -> { From fd56b5e9874f993b77c60ac419dedc54c416a106 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 21:30:57 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberModel.java | 1 + .../loopers/domain/member/MemberService.java | 8 ++- .../api/member/MemberV1ApiSpec.java | 9 +-- .../api/member/MemberV1Controller.java | 7 ++- .../interfaces/api/member/MemberV1Dto.java | 16 ++++++ .../domain/member/MemberModelTest.java | 54 ++++++++++++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 55 +++++++++++++++++++ build.gradle.kts | 7 +-- 8 files changed, 145 insertions(+), 12 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index bb3b422f..70b6bcf2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -56,6 +56,7 @@ public MemberModel(String loginId, String password, String name, String birthDat public MemberModel(String loginId) { // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค validateLoginId(loginId); + this.loginId = loginId; } public String getLoginId() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index bb6cca4a..b8593099 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -4,6 +4,7 @@ import com.loopers.support.error.ErrorType; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -26,9 +27,12 @@ public MemberModel saveMember(MemberModel memberModel) { // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ String encrypted = passwordEncoder.encode(memberModel.getPassword()); memberModel.encryptPassword(encrypted); - memberRepository.save(memberModel); - return getMember(memberModel.getLoginId()); + try { + return memberRepository.save(memberModel); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index c58dffe2..b1a6b2a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -1,11 +1,8 @@ package com.loopers.interfaces.api.member; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.PostMapping; @Tag(name = "Member V1 API", description = "ํšŒ์› API") public interface MemberV1ApiSpec { @@ -20,7 +17,11 @@ ApiResponse signUp( MemberV1Dto.SignUpRequest request ); - ApiResponse getMyInfo( + @Operation( + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "๋กœ๊ทธ์ธ ID๋กœ ๋‚ด ํšŒ์› ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค" + ) + ApiResponse getMyInfo( String loginId ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 6ca5f25c..f375fdf2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -3,10 +3,13 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -35,11 +38,11 @@ public ApiResponse signUp(@RequestBody SignUpRequest request) { @GetMapping("/{loginId}") @Override - public ApiResponse getMyInfo( + public ApiResponse getMyInfo( @PathVariable(value = "loginId") String loginId ) { MemberInfo info = memberFacade.getMyInfo(loginId); - MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + MemberInfoResponse response = MemberInfoResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index e1545299..bd29ed0f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -21,5 +21,21 @@ public static SignUpResponse from(MemberInfo info) { } } + public record MemberInfoResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MemberInfoResponse from(MemberInfo info) { + return new MemberInfoResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index b827bbdd..654ad034 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -37,6 +37,22 @@ void createsMemberModel_whenAllFieldsAreProvided() { // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋˜๋ฏ€๋กœ ์›๋ณธ๊ณผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ - ๋‚˜์ค‘์— ๊ฒ€์ฆ ๋ฐฉ์‹ ๊ฒฐ์ • } + @DisplayName("์•„์ด๋””๋กœ ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ์˜๋ฌธ๊ณผ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidChars() { + // arrange + String loginId = "testuser!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST + ); + + } + @DisplayName("(์‹คํŒจ์ผ€์ด์Šค) ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 7์ž์ผ๋•Œ, ์˜ˆ์™ธ๋ฐœ์ƒ.") @Test @@ -115,4 +131,42 @@ void throwsBadRequestException_whenPwContainsBirthDate() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("ํšŒ์›์ •๋ณด์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetMemberInfo { + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void mask_last_character() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("ํ™๊ธธ*"); + } + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void single_character_name_is_fully_masked() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("*"); + } + + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 8e2f27f1..837a4dd1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -68,6 +68,8 @@ void returnsCreated_whenValidMemberInfoIsProvided() { // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), () -> assertThat(response.getBody().data().get("loginId")).isNotNull() ); } @@ -97,4 +99,57 @@ void returnsConflict_whenDuplicateLoginIdIsProvided() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } } + + @DisplayName("GET /api/v1/members/{loginId} (ํšŒ์›์ •๋ณด์กฐํšŒ)") + @Nested + class GetMemberInfo { + + @DisplayName("์กด์žฌํ•˜๋Š” ํšŒ์›์˜ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsMemberInfo_whenExistingLoginIdIsProvided() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - GET ์š”์ฒญ์œผ๋กœ ํšŒ์› ์ •๋ณด ์กฐํšŒ + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/testuser", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("ํ™๊ธธ*") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ์›์˜ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 404 Not Found๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsNotFound_whenNonExistingLoginIdIsProvided() { + // arrange - ์•„๋ฌด ๋ฐ์ดํ„ฐ ์—†์Œ + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/nonexistent", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } } diff --git a/build.gradle.kts b/build.gradle.kts index 6f9ce28b..b7f02279 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,10 +40,9 @@ subprojects { apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - // TODO: JDK 24 ํ™˜๊ฒฝ์—์„œ JacocoReport ํƒœ์Šคํฌ ์ƒ์„ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ - JDK 21๋กœ ์ „ํ™˜ ํ›„ ํ™œ์„ฑํ™” ํ•„์š” - // if (name !in containerProjects) { - // apply(plugin = "jacoco") - // } + if (name !in containerProjects) { + apply(plugin = "jacoco") + } dependencyManagement { imports { From e6e5d94848f0dcda92960d1503332a171e6eb757 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 00:36:51 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 8 + .../application/member/MemberFacade.java | 25 +- .../loopers/domain/member/MemberModel.java | 212 +++++----- .../domain/member/MemberRepository.java | 1 + .../loopers/domain/member/MemberService.java | 25 ++ .../api/member/MemberV1ApiSpec.java | 9 + .../api/member/MemberV1Controller.java | 12 + .../interfaces/api/member/MemberV1Dto.java | 7 + .../com/loopers/support/error/ErrorType.java | 3 +- .../domain/member/MemberModelTest.java | 362 ++++++++++-------- .../member/MemberServiceIntegrationTest.java | 141 +++++-- .../interfaces/api/MemberV1ApiE2ETest.java | 96 +++++ 12 files changed, 603 insertions(+), 298 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..4cd83877 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew :apps:commerce-api:test:*)" + ] + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 12272ab3..1e9cd02b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -25,12 +25,21 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { // 2. Service ํ˜ธ์ถœ (์ €์žฅ + ์ค‘๋ณต ์ฒดํฌ) MemberModel saved = memberService.saveMember(memberModel); - // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ - return MemberInfo.from(saved); - } - - public MemberInfo getMyInfo(String loginId) { - MemberModel member = memberService.getMember(loginId); - return MemberInfo.from(member); - } + // 3. MemberModel โ†’ MemberInfo๋กœ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ˜ํ™˜ + return MemberInfo.from(saved); + } + + public MemberInfo getMyInfo(String loginId) { + MemberModel member = memberService.getMember(loginId); + return MemberInfo.from(member); + } + + public void changePassword(String loginId, String prevPassword, String newPassword) { + // Request โ†’ MemberModel๋กœ ๋ณ€ํ™˜ + MemberModel memberModel = new MemberModel(loginId, prevPassword); + + // Service ํ˜ธ์ถœ + memberService.changePassword(memberModel, newPassword); + + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 70b6bcf2..58e72de9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -10,127 +10,139 @@ @Table(name = "member") public class MemberModel extends BaseEntity { - private String loginId; - private String password ; - private String name; - private String birthDate; - private String email; - - protected MemberModel() {} - - public MemberModel(String loginId, String password, String name, String birthDate, String email) { - - // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (birthDate == null || birthDate.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ - // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค - validateLoginId(loginId); - - // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. - // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. - // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ - validatePassword(password, birthDate); - - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } + private String loginId; + private String password; + private String name; + private String birthDate; + private String email; - public MemberModel(String loginId) { - // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค - validateLoginId(loginId); - this.loginId = loginId; - } + protected MemberModel() { + } + + public MemberModel(String loginId, String password, String name, String birthDate, String email) { - public String getLoginId() { - return loginId; + // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } - public String getPassword() { - return password; + // ๊ฐ€์ž…๋œ ์•„์ด๋””๋กœ๋Š” ๊ฐ€์ž…์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค -> ๋””๋น„์—์„œ ๊ฒ€์ฆ. ์„œ๋น„์Šค์—์„œ ํ•˜๊ธฐ + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + + // ๋น„๋ฐ€๋ฒˆํ˜ธ 8~16์ž์˜ ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ƒ๋…„์›”์ผ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋‚ด์— ํฌํ•จ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public MemberModel(String loginId) { + // ๋ชจ๋“  ํ•ญ๋ชฉ์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๋‹ค + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์•„์ด๋””๋Š” ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } - public String getName() { - return name; + // ๊ฐ€์ž…ํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉํ•œ๋‹ค + validateLoginId(loginId); + this.loginId = loginId; + } + + public MemberModel(String loginId, String prevPassword) { + this.loginId = loginId; + this.password = prevPassword; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + private void validateLoginId(String loginId) { + // ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธ ์•„์ด๋””๋Š” ์˜๋ฌธ์ž์™€ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); } + } - public String getBirthDate() { - return birthDate; + private void validatePassword(String password, String birthDate) { + // 1. 8~16์ž ๊ธธ์ด ์ฒดํฌ + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - public String getEmail() { - return email; + // 2. ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ํ—ˆ์šฉ (ํ•œ๊ธ€, ๊ณต๋ฐฑ ๋“ฑ ๋ถˆ๊ฐ€) + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); } - private void validateLoginId(String loginId) { - // ๋กœ๊ทธ์ธ ID ๋Š” ์˜๋ฌธ๊ณผ ์ˆซ์ž๋งŒ ํ—ˆ์šฉ - if (!loginId.matches("^[a-zA-Z0-9]+$")) { - throw new CoreException( - ErrorType.BAD_REQUEST, - "๋กœ๊ทธ์ธ ์•„์ด๋””๋Š” ์˜๋ฌธ์ž์™€ ์ˆซ์ž๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." - ); - } + // 3. ์ƒ๋…„์›”์ผ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ์— ํฌํ•จ๋˜๋ฉด ์•ˆ๋จ + if (password.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์„ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } + } + + // ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์—”ํ‹ฐํ‹ฐ์— ๋„ฃ์–ด์ฃผ๊ธฐ + public void encryptPassword(String encryptedPassword) { + this.password = encryptedPassword; + } - private void validatePassword(String password, String birthDate) { - // 1. 8~16์ž ๊ธธ์ด ์ฒดํฌ - if (password.length() < 8 || password.length() > 16) { - throw new CoreException(ErrorType.BAD_REQUEST, - "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~16์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - // 2. ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ํ—ˆ์šฉ (ํ•œ๊ธ€, ๊ณต๋ฐฑ ๋“ฑ ๋ถˆ๊ฐ€) - if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { - throw new CoreException(ErrorType.BAD_REQUEST, - "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์˜๋ฌธ ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."); - } - - // 3. ์ƒ๋…„์›”์ผ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ์— ํฌํ•จ๋˜๋ฉด ์•ˆ๋จ - if (password.contains(birthDate)) { - throw new CoreException(ErrorType.BAD_REQUEST, - "๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์„ ํฌํ•จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } + // ์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž์— ๋งˆ์Šคํ‚น ์ถ”๊ฐ€ + public String maskLastChar(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("์ด๋ฆ„์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } - // ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์—”ํ‹ฐํ‹ฐ์— ๋„ฃ์–ด์ฃผ๊ธฐ - public void encryptPassword(String encryptedPassword) { - this.password = encryptedPassword; + if (name.length() == 1) { + return "*"; } - // ์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž์— ๋งˆ์Šคํ‚น ์ถ”๊ฐ€ - public String maskLastChar(String name) { - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("์ด๋ฆ„์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } + return name.substring(0, name.length() - 1) + "*"; + } - if (name.length() == 1) { - return "*"; - } + // ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„ ๊ฐ€์ ธ์˜ค๊ธฐ + public String getMaskedName() { + return maskLastChar(this.name); + } - return name.substring(0, name.length() - 1) + "*"; - } - // ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„ ๊ฐ€์ ธ์˜ค๊ธฐ - public String getMaskedName(){ - return maskLastChar(this.name); - } + // ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝํ•˜๊ธฐ + public void changePassword(String newPassword, String birthDate) { + validatePassword(newPassword, birthDate); + this.password = newPassword; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index b86afc71..ca1e3cd8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -6,4 +6,5 @@ public interface MemberRepository { MemberModel save(MemberModel memberModel); Optional findByLoginId(String id); Optional update(MemberModel memberModel); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index b8593099..f6ee186b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -41,4 +41,29 @@ public MemberModel getMember(String loginId) { return memberRepository.findByLoginId(model.getLoginId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + @Transactional(readOnly = false) + public void changePassword(MemberModel memberModel, String newPassword) { + + // ๊ธฐ์กด ํšŒ์› ์ •๋ณด ์กฐํšŒ + MemberModel member = getMember(memberModel.getLoginId()); + + // ์•”ํ˜ธํ™”๋œ DB ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + // ์•”ํ˜ธํ™”๋œ DB ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ฌ๋ผ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + ์•”ํ˜ธํ™” + ์ €์žฅ (Dirty Checking) + member.changePassword(newPassword, member.getBirthDate()); + + // ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ (Dirty Checking) + String encryptedPassword = passwordEncoder.encode(newPassword); + member.encryptPassword(encryptedPassword); + + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index b1a6b2a1..41128ac7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -25,4 +25,13 @@ ApiResponse getMyInfo( String loginId ); + @Operation( + summary = "๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ", + description = "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ฐ›์•„ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•œ๋‹ค" + ) + ApiResponse changePassword( + String loginId, + MemberV1Dto.ChangePasswordRequest request + ); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index f375fdf2..a7a3928e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -3,12 +3,14 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.ChangePasswordRequest; import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -46,4 +48,14 @@ public ApiResponse getMyInfo( return ApiResponse.success(response); } + @PatchMapping("/{loginId}/password") + public ApiResponse changePassword( + @PathVariable String loginId, + @RequestBody ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, request.oldPassword(), request.newPassword()); + return ApiResponse.success("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index bd29ed0f..e4e6f046 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -38,4 +38,11 @@ public static MemberInfoResponse from(MemberInfo info) { } + // Request: ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + public record ChangePasswordRequest( + String oldPassword, + String newPassword + ) {} + + } 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..96c81c9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "์œ ํšจํ•˜์ง€ ์•Š์€ ์ธ์ฆ ์ •๋ณด์ž…๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index 654ad034..e28f0cda 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -1,172 +1,220 @@ package com.loopers.domain.member; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - class MemberModelTest { - @DisplayName("ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - - @DisplayName("(์„ฑ๊ณต์ผ€์ด์Šค) ํ•„์ˆ˜ ์ •๋ณด๊ฐ€ ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createsMemberModel_whenAllFieldsAreProvided() { - // arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - // assert - assertThat(member.getLoginId()).isEqualTo(loginId); - assertThat(member.getPassword()).isEqualTo(rawPassword); - assertThat(member.getName()).isEqualTo(name); - assertThat(member.getBirthDate()).isEqualTo(birthDate); - assertThat(member.getEmail()).isEqualTo(email); - // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋˜๋ฏ€๋กœ ์›๋ณธ๊ณผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ - ๋‚˜์ค‘์— ๊ฒ€์ฆ ๋ฐฉ์‹ ๊ฒฐ์ • - } - - @DisplayName("์•„์ด๋””๋กœ ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ์˜๋ฌธ๊ณผ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsBadRequestException_whenLoginIdContainsInvalidChars() { - // arrange - String loginId = "testuser!@#"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId); - }); - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST - ); - - } - - - @DisplayName("(์‹คํŒจ์ผ€์ด์Šค) ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 7์ž์ผ๋•Œ, ์˜ˆ์™ธ๋ฐœ์ƒ.") - @Test - void throwsBadRequestException_whenPwIsOutOfRange() { - // arrange - String loginId = "testuser"; - String password = "Test12!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 17์ž์ผ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void throwsBadRequestException_whenPwIsOutOfRange2() { - // arrange - String loginId = "testuser"; - String password = "Test123456789012!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ํ•œ๊ธ€์ด ์žˆ์„ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void throwsBadRequestException_whenPwIsKorean() { - // arrange - String loginId = "testuser"; - String password = "Testํ™๊ธธ๋™890123!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋  ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void throwsBadRequestException_whenPwContainsBirthDate() { - // arrange - String loginId = "testuser"; - String password = "Test19900101!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } + @DisplayName("ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + + @DisplayName("(์„ฑ๊ณต์ผ€์ด์Šค) ํ•„์ˆ˜ ์ •๋ณด๊ฐ€ ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsMemberModel_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(member.getLoginId()).isEqualTo(loginId); + assertThat(member.getPassword()).isEqualTo(rawPassword); + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirthDate()).isEqualTo(birthDate); + assertThat(member.getEmail()).isEqualTo(email); + // ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋˜๋ฏ€๋กœ ์›๋ณธ๊ณผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ - ๋‚˜์ค‘์— ๊ฒ€์ฆ ๋ฐฉ์‹ ๊ฒฐ์ • + } + + @DisplayName("์•„์ด๋””๋กœ ํšŒ์› ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ์˜๋ฌธ๊ณผ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidChars() { + // arrange + String loginId = "testuser!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("(์‹คํŒจ์ผ€์ด์Šค) ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 7์ž์ผ๋•Œ, ์˜ˆ์™ธ๋ฐœ์ƒ.") + @Test + void throwsBadRequestException_whenPwIsOutOfRange() { + // arrange + String loginId = "testuser"; + String password = "Test12!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ 17์ž์ผ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsOutOfRange2() { + // arrange + String loginId = "testuser"; + String password = "Test123456789012!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ํ•œ๊ธ€์ด ์žˆ์„ ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwIsKorean() { + // arrange + String loginId = "testuser"; + String password = "Testํ™๊ธธ๋™890123!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋  ๋•Œ โ†’ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void throwsBadRequestException_whenPwContainsBirthDate() { + // arrange + String loginId = "testuser"; + String password = "Test19900101!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + } + + @DisplayName("ํšŒ์›์ •๋ณด์กฐํšŒํ•  ๋•Œ,") + @Nested + class GetMemberInfo { + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void mask_last_character() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("ํ™๊ธธ*"); + } + + @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") + @Test + void single_character_name_is_fully_masked() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "ํ™"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("*"); + } + + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • ํ•  ๋•Œ,") + @Nested + class ChangePassword { + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ทœ์น™์„ ๋งŒ์กฑํ•˜๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changesPassword_whenOldPasswordMatchesAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + String newPassword = "Newpass123!"; + member.changePassword(newPassword, birthDate); + + // assert + assertThat(member.getPassword()).isEqualTo(newPassword); + } + + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ƒ๋…„์›”์ผ์ด ํฌํ•จ๋˜๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNewPasswordContainsBirthDate() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + String newPassword = "Test19900101!"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + member.changePassword(newPassword, birthDate); + }); - @DisplayName("ํšŒ์›์ •๋ณด์กฐํšŒํ•  ๋•Œ,") - @Nested - class GetMemberInfo { - - @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") - @Test - void mask_last_character() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "ํ™๊ธธ๋™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("ํ™๊ธธ*"); - } - - @DisplayName("์ด๋ฆ„ ๋งˆ์ง€๋ง‰ ๊ธ€์ž๋ฅผ ๋งˆ์Šคํ‚นํ•œ๋‹ค") - @Test - void single_character_name_is_fully_masked() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "ํ™"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("*"); - } + // assert - ErrorType.BAD_REQUEST + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index d7b296cb..90820170 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -43,13 +43,7 @@ void returnsMemberInfo_whenValidMemberInfoIsProvided() { MemberModel result = memberService.getMember(memberModel.getLoginId()); // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), - () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), - () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), - () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) - ); + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); } @DisplayName("์ค‘๋ณต ID๋กœ ๊ฐ€์ž… ์‹œ๋„ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") @@ -73,31 +67,25 @@ void throwsException_whenExistIdIsTryToSaveMember() { @Nested class GetMember { - @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange // ์ •๋ณด์ €์žฅ - MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); - memberService.saveMember(memberModel); - - // act - MemberModel result = memberService.getMember(memberModel.getLoginId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), - () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), - () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), - () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - @Test - void throwsException_whenMemberNotFound() { - // arrange - String loginId = "testuser"; // Assuming this ID does not exist + @DisplayName("์กด์žฌํ•˜๋Š” ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange // ์ •๋ณด์ €์žฅ + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "ํ™๊ธธ๋™", "19900101", "test@example.com"); + memberService.saveMember(memberModel); + + // act + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist // act CoreException exception = assertThrows(CoreException.class, () -> { @@ -108,4 +96,93 @@ void throwsException_whenMemberNotFound() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } + + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ์„ ํ•  ๋•Œ, ") + @Nested + class ChangePassword { + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์œ ํšจํ•˜๋ฉด, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void changesPassword_whenOldAndNewPasswordsAreValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // ๊ธฐ์กด ๋“ฑ๋ก๋œ ๋””๋น„ ์„ค์ • + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "NewPass123!"; + + // act + memberService.changePassword(insertedMember, newPassword); + + // assert + MemberModel updatedMember = memberService.getMember("testuser"); + // ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต) + assertThat(updatedMember.getPassword()).isNotEqualTo(insertedMember.getPassword()); + } + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenOldPasswordDoesNotMatch() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // ๊ธฐ์กด ๋“ฑ๋ก๋œ ๋””๋น„ ์„ค์ • + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + String wrongPrevPassword = "WrongPass!"; + MemberModel insertedMember = new MemberModel(loginId, wrongPrevPassword); + String newPassword = "NewPass123!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๊ฐ™์œผ๋ฉด, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenNewPasswordIsSameAsOld() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "ํ™๊ธธ๋™"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // ํด๋ผ์—์„œ ์ž…๋ ฅํ•œ ์•„์ด๋””์™€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "Test1234!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED ๋˜๋Š” BAD_REQUEST + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 837a4dd1..a31933ee 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -152,4 +152,100 @@ void returnsNotFound_whenNonExistingLoginIdIsProvided() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } } + + @DisplayName("PATCH /api/v1/members/{loginId}/password (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ)") + @Nested + class ChangePassword { + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜๊ณ  ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์œ ํšจํ•˜๋ฉด, 200 OK๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenOldPasswordDoesNotMatch() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ‹€๋ฆฐ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ + Map changePasswordRequest = Map.of( + "oldPassword", "WrongPass1!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๊ฐ™์œผ๋ฉด, 400 Bad Request๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsBadRequest_whenNewPasswordIsSameAsOld() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ๊ธฐ์กด๊ณผ ๋™์ผํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "Test1234!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } } From df0e33003002447ebdffc71a6f7a644af38eddad Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 01:18:27 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=ED=97=A4=EB=8D=94=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80(=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 14 +- .../loopers/domain/member/MemberService.java | 101 ++++++----- .../api/member/MemberV1ApiSpec.java | 7 +- .../api/member/MemberV1Controller.java | 16 +- .../interfaces/api/MemberV1ApiE2ETest.java | 170 ++++++++++++++---- 5 files changed, 217 insertions(+), 91 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 1e9cd02b..781bacce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -29,14 +29,18 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { return MemberInfo.from(saved); } - public MemberInfo getMyInfo(String loginId) { - MemberModel member = memberService.getMember(loginId); + + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberService.authenticate(loginId, password); return MemberInfo.from(member); } - public void changePassword(String loginId, String prevPassword, String newPassword) { - // Request โ†’ MemberModel๋กœ ๋ณ€ํ™˜ - MemberModel memberModel = new MemberModel(loginId, prevPassword); + + public void changePassword(String loginId, String password, String prevPassword, String newPassword) { + // ํ—ค๋” ์ธ์ฆ + memberService.authenticate(loginId, password); + + MemberModel memberModel = new MemberModel(loginId, prevPassword); // raw prevPassword // Service ํ˜ธ์ถœ memberService.changePassword(memberModel, newPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index f6ee186b..0ab66665 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -13,57 +13,70 @@ @Component public class MemberService { - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional(readOnly = false) - public MemberModel saveMember(MemberModel memberModel) { - //์ €์žฅํ•˜๊ธฐ ์ „์— ์ด๋ฏธ ๊ฐ™์€ loginId๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ - Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); - if (existing.isPresent()) { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); - } - - // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ - String encrypted = passwordEncoder.encode(memberModel.getPassword()); - memberModel.encryptPassword(encrypted); - - try { - return memberRepository.save(memberModel); - } catch (DataIntegrityViolationException e) { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); - } - } - - @Transactional(readOnly = true) - public MemberModel getMember(String loginId) { - MemberModel model = new MemberModel(loginId); // ๊ฐ์ฒด ๋จผ์ € ์ƒ์„ฑํ•ด์•ผ ํ•จ - return memberRepository.findByLoginId(model.getLoginId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = false) + public MemberModel saveMember(MemberModel memberModel) { + //์ €์žฅํ•˜๊ธฐ ์ „์— ์ด๋ฏธ ๊ฐ™์€ loginId๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); + if (existing.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); } - @Transactional(readOnly = false) - public void changePassword(MemberModel memberModel, String newPassword) { + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ + String encrypted = passwordEncoder.encode(memberModel.getPassword()); + memberModel.encryptPassword(encrypted); + + try { + return memberRepository.save(memberModel); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."); + } + } + + public MemberModel authenticate(String loginId, String password) { + // 1. ํšŒ์› ์กฐํšŒ + MemberModel member = getMember(loginId); // ์—†์œผ๋ฉด NOT_FOUND + + // 2. ๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜ ์—ฌ๋ถ€ ํ™•์ธ + if (!passwordEncoder.matches(password, member.getPassword())) { + // 3. ๋ถˆ์ผ์น˜ ์‹œ UNAUTHORIZED ์˜ˆ์™ธ + throw new CoreException(ErrorType.UNAUTHORIZED, "์ธ์ฆ ์‹คํŒจ"); + } - // ๊ธฐ์กด ํšŒ์› ์ •๋ณด ์กฐํšŒ - MemberModel member = getMember(memberModel.getLoginId()); + return member; + } - // ์•”ํ˜ธํ™”๋œ DB ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต - if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } + @Transactional(readOnly = true) + public MemberModel getMember(String loginId) { + MemberModel model = new MemberModel(loginId); // ๊ฐ์ฒด ๋จผ์ € ์ƒ์„ฑํ•ด์•ผ ํ•จ + return memberRepository.findByLoginId(model.getLoginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } - // ์•”ํ˜ธํ™”๋œ DB ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต - if (passwordEncoder.matches(newPassword, member.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ฌ๋ผ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } + @Transactional(readOnly = false) + public void changePassword(MemberModel memberModel, String newPassword) { - // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + ์•”ํ˜ธํ™” + ์ €์žฅ (Dirty Checking) - member.changePassword(newPassword, member.getBirthDate()); + // ๊ธฐ์กด ํšŒ์› ์ •๋ณด ์กฐํšŒ + MemberModel member = getMember(memberModel.getLoginId()); - // ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ (Dirty Checking) - String encryptedPassword = passwordEncoder.encode(newPassword); - member.encryptPassword(encryptedPassword); + // ์•”ํ˜ธํ™”๋œ DB ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + // ์•”ํ˜ธํ™”๋œ DB ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ž…๋ ฅํ•œ ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋‹ฌ๋ผ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } + + // ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ทœ์น™ ๊ฒ€์ฆ + ์•”ํ˜ธํ™” + ์ €์žฅ (Dirty Checking) + member.changePassword(newPassword, member.getBirthDate()); + + // ์•”ํ˜ธํ™” ํ›„ ์ €์žฅ (Dirty Checking) + String encryptedPassword = passwordEncoder.encode(newPassword); + member.encryptPassword(encryptedPassword); + + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 41128ac7..52cb6480 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -3,6 +3,7 @@ import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; @Tag(name = "Member V1 API", description = "ํšŒ์› API") public interface MemberV1ApiSpec { @@ -22,7 +23,8 @@ ApiResponse signUp( description = "๋กœ๊ทธ์ธ ID๋กœ ๋‚ด ํšŒ์› ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค" ) ApiResponse getMyInfo( - String loginId + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password ); @Operation( @@ -30,7 +32,8 @@ ApiResponse getMyInfo( description = "๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ฐ›์•„ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•œ๋‹ค" ) ApiResponse changePassword( - String loginId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, MemberV1Dto.ChangePasswordRequest request ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index a7a3928e..07bf218e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -11,9 +11,9 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -38,22 +38,24 @@ public ApiResponse signUp(@RequestBody SignUpRequest request) { return ApiResponse.success(response); } - @GetMapping("/{loginId}") + @GetMapping("/me") @Override public ApiResponse getMyInfo( - @PathVariable(value = "loginId") String loginId + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password ) { - MemberInfo info = memberFacade.getMyInfo(loginId); + MemberInfo info = memberFacade.getMyInfo(loginId, password); MemberInfoResponse response = MemberInfoResponse.from(info); return ApiResponse.success(response); } - @PatchMapping("/{loginId}/password") + @PatchMapping("/me/password") public ApiResponse changePassword( - @PathVariable String loginId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, @RequestBody ChangePasswordRequest request ) { - memberFacade.changePassword(loginId, request.oldPassword(), request.newPassword()); + memberFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); return ApiResponse.success("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index a31933ee..b9fb6e26 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -1,7 +1,12 @@ package com.loopers.interfaces.api; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -10,15 +15,11 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class MemberV1ApiE2ETest { @@ -69,8 +70,14 @@ void returnsCreated_whenValidMemberInfoIsProvided() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().get("loginId")).isNotNull() + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isNotNull(); + } ); } @@ -100,13 +107,13 @@ void returnsConflict_whenDuplicateLoginIdIsProvided() { } } - @DisplayName("GET /api/v1/members/{loginId} (ํšŒ์›์ •๋ณด์กฐํšŒ)") + @DisplayName("GET /api/v1/members/me (ํšŒ์›์ •๋ณด์กฐํšŒ)") @Nested class GetMemberInfo { - @DisplayName("์กด์žฌํ•˜๋Š” ํšŒ์›์˜ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("ํ—ค๋” ์ธ์ฆ ์„ฑ๊ณต ์‹œ, 200 OK์™€ ๋งˆ์Šคํ‚น๋œ ์ด๋ฆ„์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test - void returnsMemberInfo_whenExistingLoginIdIsProvided() { + void returnsMemberInfo_whenHeaderAuthIsValid() { // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… Map signUpRequest = Map.of( "loginId", "testuser", @@ -117,11 +124,15 @@ void returnsMemberInfo_whenExistingLoginIdIsProvided() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - GET ์š”์ฒญ์œผ๋กœ ํšŒ์› ์ •๋ณด ์กฐํšŒ + // act - ํ—ค๋”์— ์ธ์ฆ ์ •๋ณด ํฌํ•จํ•˜์—ฌ ์กฐํšŒ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + ResponseEntity>> response = testRestTemplate.exchange( - ENDPOINT + "/testuser", + ENDPOINT + "/me", HttpMethod.GET, - null, + new HttpEntity<>(headers), new ParameterizedTypeReference<>() {} ); @@ -129,22 +140,63 @@ void returnsMemberInfo_whenExistingLoginIdIsProvided() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"), - () -> assertThat(response.getBody().data().get("name")).isEqualTo("ํ™๊ธธ*") + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("name")).isEqualTo("ํ™๊ธธ*"); + } ); } + @DisplayName("ํ—ค๋” ์ธ์ฆ ์‹คํŒจ ์‹œ (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜), 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ‹€๋ฆฐ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์กฐํšŒ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ์›์˜ ID๋กœ ์กฐํšŒํ•˜๋ฉด, 404 Not Found๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test - void returnsNotFound_whenNonExistingLoginIdIsProvided() { + void returnsNotFound_whenMemberDoesNotExist() { // arrange - ์•„๋ฌด ๋ฐ์ดํ„ฐ ์—†์Œ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); // act ResponseEntity>> response = testRestTemplate.exchange( - ENDPOINT + "/nonexistent", + ENDPOINT + "/me", HttpMethod.GET, - null, + new HttpEntity<>(headers), new ParameterizedTypeReference<>() {} ); @@ -153,13 +205,13 @@ void returnsNotFound_whenNonExistingLoginIdIsProvided() { } } - @DisplayName("PATCH /api/v1/members/{loginId}/password (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ)") + @DisplayName("PATCH /api/v1/members/me/password (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ)") @Nested class ChangePassword { - @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜๊ณ  ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์œ ํšจํ•˜๋ฉด, 200 OK๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("ํ—ค๋” ์ธ์ฆ ์„ฑ๊ณต + ์œ ํšจํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ์ด๋ฉด, 200 OK๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test - void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { + void returnsOk_whenHeaderAuthAndPasswordChangeAreValid() { // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… Map signUpRequest = Map.of( "loginId", "testuser", @@ -170,15 +222,20 @@ void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + // act - ํ—ค๋” ์ธ์ฆ + ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "Test1234!", "newPassword", "NewPass123!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); @@ -186,13 +243,16 @@ void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isEqualTo("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } ); } - @DisplayName("๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("ํ—ค๋” ์ธ์ฆ ์‹คํŒจ ์‹œ (๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜), 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test - void returnsUnauthorized_whenOldPasswordDoesNotMatch() { + void returnsUnauthorized_whenHeaderPasswordIsWrong() { // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… Map signUpRequest = Map.of( "loginId", "testuser", @@ -203,15 +263,54 @@ void returnsUnauthorized_whenOldPasswordDoesNotMatch() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - ํ‹€๋ฆฐ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ + // act - ํ‹€๋ฆฐ ํ—ค๋” ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("Body์˜ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์œผ๋ฉด, 401 Unauthorized๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUnauthorized_whenOldPasswordInBodyDoesNotMatch() { + // arrange - ๋จผ์ € ํšŒ์› ๊ฐ€์ž… + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "ํ™๊ธธ๋™", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - ํ—ค๋”๋Š” ๋งž์ง€๋งŒ Body์˜ oldPassword๊ฐ€ ํ‹€๋ฆผ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "WrongPass1!", "newPassword", "NewPass123!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); @@ -233,14 +332,19 @@ void returnsBadRequest_whenNewPasswordIsSameAsOld() { testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); // act - ๊ธฐ์กด๊ณผ ๋™์ผํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "Test1234!", "newPassword", "Test1234!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); From 2f40dc76da2be94955326c43520eac84c782df6c Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 01:37:18 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=ED=9A=8C=EC=9B=90=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ํšŒ์›๊ฐ€์ž… ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ (ํ•ต์‹ฌ + ์˜ˆ์™ธ ํ”Œ๋กœ์šฐ) - ๋‚ด ์ •๋ณด ์กฐํšŒ ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ (ํ—ค๋” ์ธ์ฆ ํฌํ•จ) - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ (ํ•ต์‹ฌ + ์˜ˆ์™ธ ํ”Œ๋กœ์šฐ) Co-Authored-By: Claude Opus 4.5 --- MERMAID.md | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 MERMAID.md diff --git a/MERMAID.md b/MERMAID.md new file mode 100644 index 00000000..09f77cf3 --- /dev/null +++ b/MERMAID.md @@ -0,0 +1,167 @@ +# Flow Diagrams + +## 1. ํšŒ์›๊ฐ€์ž… (POST /api/v1/members) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signupMember(request) + Facade->>Facade: Request to MemberModel ๋ณ€ํ™˜ + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId (์ค‘๋ณต ์ฒดํฌ) + DB-->>Service: Optional.empty() + Service->>Service: passwordEncoder.encode() + Service->>DB: save(memberModel) + DB-->>Service: savedMember + Service-->>Facade: MemberModel + Facade->>Facade: MemberModel to MemberInfo ๋ณ€ํ™˜ + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (์ค‘๋ณต ID) + Controller->>Facade: signupMember(request) + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId + DB-->>Service: Optional.of(existingMember) + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict +``` + +--- + +## 2. ๋‚ด ์ •๋ณด ์กฐํšŒ (GET /api/v1/members/me) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Controller->>Facade: getMyInfo(loginId, password) + Facade->>Service: authenticate(loginId, password) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() + Service-->>Facade: MemberModel (์ธ์ฆ ์„ฑ๊ณต) + Facade->>Facade: MemberModel to MemberInfo ๋ณ€ํ™˜ (์ด๋ฆ„ ๋งˆ์Šคํ‚น) + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MemberInfoResponse) +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - ์ธ์ฆ ์‹คํŒจ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me (ํ‹€๋ฆฐ ๋น„๋ฐ€๋ฒˆํ˜ธ) + Controller->>Facade: getMyInfo(loginId, wrongPassword) + Facade->>Service: authenticate(loginId, wrongPassword) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +--- + +## 3. ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ (PATCH /api/v1/members/me/password) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: PATCH /api/v1/members/me/password + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over Client,Controller: Body: oldPassword, newPassword + Controller->>Facade: changePassword(loginId, headerPw, oldPw, newPw) + Facade->>Service: authenticate(loginId, headerPw) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches(headerPw) + Service-->>Facade: ์ธ์ฆ ์„ฑ๊ณต + Facade->>Facade: new MemberModel(loginId, oldPw) + Facade->>Service: changePassword(memberModel, newPw) + Service->>Service: passwordEncoder.matches(oldPw) ๊ฒ€์ฆ + Service->>Service: newPw != oldPw ๊ฒ€์ฆ + Service->>Service: validatePassword(newPw) ๊ทœ์น™ ๊ฒ€์ฆ + Service->>Service: passwordEncoder.encode(newPw) + Service->>DB: Dirty Checking (์ž๋™ ์ €์žฅ) + Service-->>Facade: void + Facade-->>Controller: void + Controller-->>Client: 200 OK +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - Body์˜ ๊ธฐ์กด ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถˆ์ผ์น˜ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (ํ—ค๋” ์ธ์ฆ OK, Body oldPw ํ‹€๋ฆผ) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() ์„ฑ๊ณต + Facade->>Service: changePassword(wrongOldPw, newPw) + Service->>Service: passwordEncoder.matches(wrongOldPw) = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +### ์˜ˆ์™ธ ํ๋ฆ„ - ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๊ธฐ์กด๊ณผ ๋™์ผ + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (newPw == oldPw) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() ์„ฑ๊ณต + Facade->>Service: changePassword(oldPw, samePassword) + Service->>Service: oldPw ๊ฒ€์ฆ ์„ฑ๊ณต + Service->>Service: newPw == oldPw ์ฒดํฌ + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request +``` \ No newline at end of file