From efc51f0086969a96d582eeb0b3863cad77580144 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 16:56:09 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20PasswordEncoder=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/member/PasswordEncoder.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java new file mode 100644 index 00000000..a28f6d47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.loopers.domain.member; + +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file From 2ff2f5ec3447774ecc522f60d8e7203d550c2b62 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 16:56:55 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=8A=A5=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 | 57 +++++++++++++++++++ .../domain/member/MemberModelTest.java | 55 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java 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..1f2dc5e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,57 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + private String loginId; + private String password; + private String name; + private LocalDate birthDate; + private String email; + + protected MemberModel() {} + + private MemberModel(String loginId, String password, String name, + LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static MemberModel create(String loginId, String rawPassword, + String name, LocalDate birthDate, + String email, PasswordEncoder encoder) { + String encodedPassword = encoder.encode(rawPassword); + return new MemberModel(loginId, encodedPassword, name, birthDate, email); + } + + // Getter + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public String getEmail() { + return 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 new file mode 100644 index 00000000..c15ce77e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.member; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberModelTest { + + private final PasswordEncoder stubEncoder = new PasswordEncoder() { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } + }; + + @DisplayName("회원을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 정보가 유효하면, 정상적으로 생성된다.") + @Test + void createsMember_whenAllFieldsAreValid() { + // Arrange + String loginId = "testuser1"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + // Act + MemberModel member = MemberModel.create( + loginId, password, name, birthDate, email, stubEncoder + ); + + // Assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), // Stub이 반환한 값 + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + } +} From 08b552287d58517628c14cbef0f0b7d5df20acf9 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 16:57:27 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8ID=20?= =?UTF-8?q?=EC=98=81=EB=AC=B8/=EC=88=AB=EC=9E=90=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberModel.java | 10 +++++++ .../domain/member/MemberModelTest.java | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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 1f2dc5e3..10d6f1df 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 @@ -1,6 +1,8 @@ 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; @@ -30,10 +32,18 @@ private MemberModel(String loginId, String password, String name, public static MemberModel create(String loginId, String rawPassword, String name, LocalDate birthDate, String email, PasswordEncoder encoder) { + validateLoginId(loginId); + String encodedPassword = encoder.encode(rawPassword); return new MemberModel(loginId, encodedPassword, name, birthDate, email); } + private static void validateLoginId(String loginId) { + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 영문과 숫자만 허용됩니다."); + } + } + // Getter public String getLoginId() { return loginId; 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 c15ce77e..7198d599 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 @@ -6,8 +6,12 @@ import java.time.LocalDate; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; class MemberModelTest { @@ -52,4 +56,27 @@ void createsMember_whenAllFieldsAreValid() { ); } } + + @DisplayName("로그인ID 검증 시, ") + @Nested + class ValidateLoginId { + + @DisplayName("영문과 숫자 외 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // Arrange + String invalidLoginId = "test@user"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + MemberModel.create( + invalidLoginId, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } From d300c29a89cab2f89905bebc13331dfc3b358f3d Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 17:02:37 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberModel.java | 14 ++++ .../domain/member/MemberModelTest.java | 78 +++++++++++++++++++ 2 files changed, 92 insertions(+) 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 10d6f1df..bbfc3bd6 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 @@ -33,11 +33,25 @@ public static MemberModel create(String loginId, String rawPassword, String name, LocalDate birthDate, String email, PasswordEncoder encoder) { validateLoginId(loginId); + validatePassword(rawPassword, birthDate); String encodedPassword = encoder.encode(rawPassword); return new MemberModel(loginId, encodedPassword, name, birthDate, email); } + private static void validatePassword(String password, LocalDate birthDate) { + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + } + String birthDateStr = birthDate.toString().replace("-", ""); // 19900115 + if (password.contains(birthDateStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + private static void validateLoginId(String loginId) { if (!loginId.matches("^[a-zA-Z0-9]+$")) { throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 영문과 숫자만 허용됩니다."); 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 7198d599..406cad2e 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 @@ -79,4 +79,82 @@ void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("비밀번호 검증 시, ") + @Nested + class ValidatePassword { + + @DisplayName("8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // Arrange + String shortPassword = "Test12!"; // 7자 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + MemberModel.create( + "testuser1", shortPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("16자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // Arrange + String longPassword = "Test1234!Test1234"; // 17자 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + MemberModel.create( + "testuser1", longPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("허용되지 않은 문자(한글)가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsInvalidCharacters() { + // Arrange + String invalidPassword = "Test123한글!"; // 한글 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + MemberModel.create( + "testuser1", invalidPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate() { + // Arrange + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String passwordWithBirthDate = "Test19900115!"; // 생년월일 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + MemberModel.create( + "testuser1", passwordWithBirthDate, "홍길동", + birthDate, "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } From 9e9781652033ced7e5c2e3b3186b1c482a0366b2 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 17:09:16 +0900 Subject: [PATCH 05/20] =?UTF-8?q?refactor:=20MemberModel=EC=9D=84=20Member?= =?UTF-8?q?=EB=A1=9C=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/{MemberModel.java => Member.java} | 16 ++++++++-------- .../{MemberModelTest.java => MemberTest.java} | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/domain/member/{MemberModel.java => Member.java} (80%) rename apps/commerce-api/src/test/java/com/loopers/domain/member/{MemberModelTest.java => MemberTest.java} (95%) 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/Member.java similarity index 80% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index bbfc3bd6..950fbd55 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/Member.java @@ -10,7 +10,7 @@ @Entity @Table(name = "member") -public class MemberModel extends BaseEntity { +public class Member extends BaseEntity { private String loginId; private String password; @@ -18,10 +18,10 @@ public class MemberModel extends BaseEntity { private LocalDate birthDate; private String email; - protected MemberModel() {} + protected Member() {} - private MemberModel(String loginId, String password, String name, - LocalDate birthDate, String email) { + private Member(String loginId, String password, String name, + LocalDate birthDate, String email) { this.loginId = loginId; this.password = password; this.name = name; @@ -29,14 +29,14 @@ private MemberModel(String loginId, String password, String name, this.email = email; } - public static MemberModel create(String loginId, String rawPassword, - String name, LocalDate birthDate, - String email, PasswordEncoder encoder) { + public static Member create(String loginId, String rawPassword, + String name, LocalDate birthDate, + String email, PasswordEncoder encoder) { validateLoginId(loginId); validatePassword(rawPassword, birthDate); String encodedPassword = encoder.encode(rawPassword); - return new MemberModel(loginId, encodedPassword, name, birthDate, email); + return new Member(loginId, encodedPassword, name, birthDate, email); } private static void validatePassword(String password, LocalDate birthDate) { 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/MemberTest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 406cad2e..a51eaada 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/MemberTest.java @@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; -class MemberModelTest { +class MemberTest { private final PasswordEncoder stubEncoder = new PasswordEncoder() { @Override @@ -42,7 +42,7 @@ void createsMember_whenAllFieldsAreValid() { String email = "test@example.com"; // Act - MemberModel member = MemberModel.create( + Member member = Member.create( loginId, password, name, birthDate, email, stubEncoder ); @@ -69,7 +69,7 @@ void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - MemberModel.create( + Member.create( invalidLoginId, "Test1234!", "홍길동", LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder ); @@ -92,7 +92,7 @@ void throwsBadRequest_whenPasswordIsTooShort() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - MemberModel.create( + Member.create( "testuser1", shortPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder ); @@ -110,7 +110,7 @@ void throwsBadRequest_whenPasswordIsTooLong() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - MemberModel.create( + Member.create( "testuser1", longPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder ); @@ -128,7 +128,7 @@ void throwsBadRequest_whenPasswordContainsInvalidCharacters() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - MemberModel.create( + Member.create( "testuser1", invalidPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder ); @@ -147,7 +147,7 @@ void throwsBadRequest_whenPasswordContainsBirthDate() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - MemberModel.create( + Member.create( "testuser1", passwordWithBirthDate, "홍길동", birthDate, "test@example.com", stubEncoder ); From 17989266b725202f34f6d6594c9d3f69659139b0 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 17:16:30 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A6=84/=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/Member.java | 17 ++++ .../com/loopers/domain/member/MemberTest.java | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index 950fbd55..a0a86ec1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -34,6 +34,8 @@ public static Member create(String loginId, String rawPassword, String email, PasswordEncoder encoder) { validateLoginId(loginId); validatePassword(rawPassword, birthDate); + validateName(name); + validateEmail(email); String encodedPassword = encoder.encode(rawPassword); return new Member(loginId, encodedPassword, name, birthDate, email); @@ -58,6 +60,21 @@ private static void validateLoginId(String loginId) { } } + private static void validateName(String name) { + boolean isKorean = name.matches("^[가-힣]+$"); + boolean isEnglish = name.matches("^[a-zA-Z]+( [a-zA-Z]+)*$"); + + if (!isKorean && !isEnglish) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글만 또는 영문만 허용됩니다."); + } + } + + private static void validateEmail(String email) { + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다."); + } + } + // Getter public String getLoginId() { return loginId; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index a51eaada..e17a43e0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -157,4 +157,86 @@ void throwsBadRequest_whenPasswordContainsBirthDate() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("이름 검증 시, ") + @Nested + class ValidateName { + + @DisplayName("한글과 영문이 혼합되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameContainsMixedLanguages() { + // Arrange + String mixedName = "Hong길동"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", mixedName, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글 이름에 공백이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenKoreanNameContainsSpace() { + // Arrange + String koreanNameWithSpace = "홍 길동"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", koreanNameWithSpace, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문 이름에 연속 공백이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEnglishNameContainsConsecutiveSpaces() { + // Arrange + String nameWithConsecutiveSpaces = "John Doe"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", nameWithConsecutiveSpaces, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이메일 검증 시, ") + @Nested + class ValidateEmail { + + @DisplayName("올바르지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // Arrange + String invalidEmail = "invalid-email"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), invalidEmail, stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } From 86fe2ecbe03e896f05b1c27eaf6f87fda8acaab0 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 17:26:49 +0900 Subject: [PATCH 07/20] =?UTF-8?q?refactor:=20=EC=98=81=EB=AC=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=97=B0=EC=86=8D=20=EA=B3=B5=EB=B0=B1=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/member/Member.java | 9 +++++++-- .../com/loopers/domain/member/MemberTest.java | 18 ++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index a0a86ec1..77da26dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -34,11 +34,12 @@ public static Member create(String loginId, String rawPassword, String email, PasswordEncoder encoder) { validateLoginId(loginId); validatePassword(rawPassword, birthDate); - validateName(name); + String normalizedName = normalizeName(name); + validateName(normalizedName); validateEmail(email); String encodedPassword = encoder.encode(rawPassword); - return new Member(loginId, encodedPassword, name, birthDate, email); + return new Member(loginId, encodedPassword, normalizedName, birthDate, email); } private static void validatePassword(String password, LocalDate birthDate) { @@ -60,6 +61,10 @@ private static void validateLoginId(String loginId) { } } + private static String normalizeName(String name) { + return name.trim().replaceAll("\\s+", " "); + } + private static void validateName(String name) { boolean isKorean = name.matches("^[가-힣]+$"); boolean isEnglish = name.matches("^[a-zA-Z]+( [a-zA-Z]+)*$"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index e17a43e0..c4d57f9d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -198,22 +198,20 @@ void throwsBadRequest_whenKoreanNameContainsSpace() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("영문 이름에 연속 공백이 포함되면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("영문 이름의 연속 공백은 하나로 정규화된다.") @Test - void throwsBadRequest_whenEnglishNameContainsConsecutiveSpaces() { + void normalizesConsecutiveSpaces_whenEnglishNameHasMultipleSpaces() { // Arrange String nameWithConsecutiveSpaces = "John Doe"; - // Act & Assert - CoreException exception = assertThrows(CoreException.class, () -> { - Member.create( - "testuser1", "Test1234!", nameWithConsecutiveSpaces, - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder - ); - }); + // Act + Member member = Member.create( + "testuser1", "Test1234!", nameWithConsecutiveSpaces, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); // Assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(member.getName()).isEqualTo("John Doe"); } } From f0e6eda3a9670648bf5078b78e6d56a3cacc96dd Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 19:28:32 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20Member=20Reader/Repository=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/member/MemberReader.java | 5 +++++ .../java/com/loopers/domain/member/MemberRepository.java | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java new file mode 100644 index 00000000..e44ee873 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java @@ -0,0 +1,5 @@ +package com.loopers.domain.member; + +public interface MemberReader { + boolean existsByLoginId(String loginId); +} 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..39bd429e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.member; + +public interface MemberRepository { + Member save(Member member); +} From ae66d538efc0b3d069564c5e5aaf29cbbbc0b550 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 20:07:01 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20MemberService=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberService.java | 30 +++++ .../domain/member/MemberServiceTest.java | 116 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java 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..adb6120a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; + +public class MemberService { + + private final MemberReader memberReader; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public MemberService(MemberReader memberReader, MemberRepository memberRepository, + PasswordEncoder passwordEncoder) { + this.memberReader = memberReader; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + } + + public Member register(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + if (memberReader.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 로그인ID입니다."); + } + + Member member = Member.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); + return memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 00000000..82275c4b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberServiceTest { + + private MemberService memberService; + private FakeMemberReader fakeMemberReader; + private FakeMemberRepository fakeMemberRepository; + private StubPasswordEncoder stubPasswordEncoder; + + @BeforeEach + void setUp() { + fakeMemberReader = new FakeMemberReader(); + fakeMemberRepository = new FakeMemberRepository(); + stubPasswordEncoder = new StubPasswordEncoder(); + memberService = new MemberService(fakeMemberReader, fakeMemberRepository, stubPasswordEncoder); + } + + @DisplayName("회원가입 시, ") + @Nested + class Register { + + @DisplayName("이미 존재하는 로그인ID로 가입하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdAlreadyExists() { + // Arrange + String existingLoginId = "existingUser"; + fakeMemberReader.addExistingLoginId(existingLoginId); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.register( + existingLoginId, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com" + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 정보로 가입하면, 회원이 저장된다.") + @Test + void savesMember_whenAllFieldsAreValid() { + // Arrange + String loginId = "newUser"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + // Act + Member member = memberService.register(loginId, password, name, birthDate, email); + + // Assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + } + + // Fake 구현체 + static class FakeMemberReader implements MemberReader { + private final Map existingLoginIds = new HashMap<>(); + + void addExistingLoginId(String loginId) { + existingLoginIds.put(loginId, true); + } + + @Override + public boolean existsByLoginId(String loginId) { + return existingLoginIds.containsKey(loginId); + } + } + + static class FakeMemberRepository implements MemberRepository { + private final Map members = new HashMap<>(); + private long idSequence = 1L; + + @Override + public Member save(Member member) { + members.put(idSequence++, member); + return member; + } + } + + static class StubPasswordEncoder implements PasswordEncoder { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } + } +} From 48123b35a4ab3db2ac804e944a5d888ce53d3d07 Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 21:31:34 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20Member=20Infrastructure=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 3 +++ .../member/MemberJpaRepository.java | 8 ++++++++ .../member/MemberReaderImpl.java | 16 +++++++++++++++ .../member/MemberRepositoryImpl.java | 17 ++++++++++++++++ .../member/PasswordEncoderImpl.java | 20 +++++++++++++++++++ 5 files changed, 64 insertions(+) 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/MemberReaderImpl.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/infrastructure/member/PasswordEncoderImpl.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..9d7d11d8 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -9,6 +9,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + + // security + implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // querydsl 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..7f5bc9e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java new file mode 100644 index 00000000..20ee3a90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberReaderImpl implements MemberReader { + private final MemberJpaRepository memberJpaRepository; + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } +} 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..011b083d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java new file mode 100644 index 00000000..e5275ed2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class PasswordEncoderImpl implements PasswordEncoder { + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return bCryptPasswordEncoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return bCryptPasswordEncoder.matches(rawPassword, encodedPassword); + } +} From fa2ad356059aaefd65594930e6b38487d8bc6f2b Mon Sep 17 00:00:00 2001 From: juoklee Date: Sun, 1 Feb 2026 21:31:46 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/config/SecurityConfig.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java new file mode 100644 index 00000000..34b243ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 12/20] 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 e4bb0f99c12d015e2b6b4e705e4999d955b78dd5 Mon Sep 17 00:00:00 2001 From: juoklee Date: Mon, 2 Feb 2026 22:17:11 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 21 +++ .../application/member/MemberInfo.java | 16 ++ .../loopers/domain/member/MemberService.java | 11 +- .../api/member/MemberV1Controller.java | 36 +++++ .../interfaces/api/member/MemberV1Dto.java | 32 ++++ .../interfaces/api/MemberV1ApiE2ETest.java | 150 ++++++++++++++++++ http/commerce-api/member-v1.http | 49 ++++++ 7 files changed, 308 insertions(+), 7 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/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/interfaces/api/MemberV1ApiE2ETest.java create mode 100644 http/commerce-api/member-v1.http 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..91e02c0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,21 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo register(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + Member member = memberService.register(loginId, rawPassword, name, birthDate, email); + 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 new file mode 100644 index 00000000..0e74dd5a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,16 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +import java.time.LocalDate; + +public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } +} 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 adb6120a..ead31501 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 @@ -2,22 +2,19 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; import java.time.LocalDate; +@RequiredArgsConstructor +@Component public class MemberService { private final MemberReader memberReader; private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - public MemberService(MemberReader memberReader, MemberRepository memberRepository, - PasswordEncoder passwordEncoder) { - this.memberReader = memberReader; - this.memberRepository = memberRepository; - this.passwordEncoder = passwordEncoder; - } - public Member register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { if (memberReader.existsByLoginId(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 new file mode 100644 index 00000000..adb4f3bf --- /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 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 { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + @RequestBody MemberV1Dto.RegisterRequest request + ) { + MemberInfo info = memberFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + MemberV1Dto.RegisterResponse response = MemberV1Dto.RegisterResponse.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..5be7b819 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record RegisterResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static RegisterResponse from(MemberInfo info) { + return new RegisterResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } +} 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..835d865a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,150 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.member.MemberJpaRepository; +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.time.LocalDate; + +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_REGISTER = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final MemberJpaRepository memberJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberJpaRepository memberJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberJpaRepository = memberJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (회원가입)") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 201 Created 응답을 받는다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + RegisterRequest request = new RegisterRequest( + "testUser1", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testUser1"), + () -> assertThat(memberJpaRepository.existsByLoginId("testUser1")).isTrue() + ); + } + + @DisplayName("이미 존재하는 로그인ID로 가입하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenLoginIdAlreadyExists() { + // arrange - 먼저 회원가입 + RegisterRequest firstRequest = new RegisterRequest( + "existingUser", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(firstRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 같은 로그인ID로 다시 가입 시도 + RegisterRequest duplicateRequest = new RegisterRequest( + "existingUser", + "Test5678!", + "김철수", + LocalDate.of(1985, 5, 20), + "second@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(duplicateRequest), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 이메일 형식으로 가입하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenInvalidEmail() { + // arrange + RegisterRequest request = new RegisterRequest( + "testUser2", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "invalid-email" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // 테스트용 Request/Response record (실제 DTO가 없으므로 임시 정의) + record RegisterRequest(String loginId, String password, String name, LocalDate birthDate, String email) {} + record RegisterResponse(String loginId, String name, LocalDate birthDate, String email) {} +} diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 00000000..90dafde2 --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,49 @@ +@baseUrl = http://localhost:8080 + +### 회원가입 (정상) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser1", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} + +### 회원가입 (중복 ID 테스트 - 위 요청 먼저 실행 후) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser1", + "password": "Test5678!", + "name": "김철수", + "birthDate": "1985-05-20", + "email": "second@example.com" +} + +### 회원가입 (잘못된 이메일) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser2", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "invalid-email" +} + +### 회원가입 (잘못된 비밀번호 - 너무 짧음) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser3", + "password": "short", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test3@example.com" +} From 85c15625ccbfe2aae7f1481a62a0f4b2421a359d Mon Sep 17 00:00:00 2001 From: juoklee Date: Wed, 4 Feb 2026 23:13:11 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 4 + .../application/member/MemberInfo.java | 19 +++ .../loopers/domain/member/MemberReader.java | 3 + .../member/MemberJpaRepository.java | 3 + .../member/MemberReaderImpl.java | 8 ++ .../api/member/MemberV1Controller.java | 15 +- .../interfaces/api/member/MemberV1Dto.java | 6 +- .../support/auth/MemberAuthFilter.java | 76 ++++++++++ .../support/config/SecurityConfig.java | 7 + .../domain/member/MemberServiceTest.java | 6 + .../interfaces/api/MemberV1ApiE2ETest.java | 131 ++++++++++++++++-- http/commerce-api/member-v1.http | 17 +++ 12 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.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 index 91e02c0d..891b34e7 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 @@ -18,4 +18,8 @@ public MemberInfo register(String loginId, String rawPassword, String name, Member member = memberService.register(loginId, rawPassword, name, birthDate, email); return MemberInfo.from(member); } + + public MemberInfo getMe(Member authenticatedMember) { + return MemberInfo.fromWithMaskedName(authenticatedMember); + } } 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 0e74dd5a..dbee777e 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 @@ -13,4 +13,23 @@ public static MemberInfo from(Member member) { member.getEmail() ); } + + public static MemberInfo fromWithMaskedName(Member member) { + return new MemberInfo( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java index e44ee873..ef6b0660 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java @@ -1,5 +1,8 @@ package com.loopers.domain.member; +import java.util.Optional; + public interface MemberReader { boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); } 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 index 7f5bc9e3..ba8b089f 100644 --- 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 @@ -3,6 +3,9 @@ import com.loopers.domain.member.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberJpaRepository extends JpaRepository { boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java index 20ee3a90..8dd8ce9c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java @@ -1,9 +1,12 @@ package com.loopers.infrastructure.member; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberReader; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Optional; + @RequiredArgsConstructor @Component public class MemberReaderImpl implements MemberReader { @@ -13,4 +16,9 @@ public class MemberReaderImpl implements MemberReader { public boolean existsByLoginId(String loginId) { return memberJpaRepository.existsByLoginId(loginId); } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(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 adb4f3bf..149ac19c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -2,9 +2,12 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; +import com.loopers.domain.member.Member; import com.loopers.interfaces.api.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -20,7 +23,7 @@ public class MemberV1Controller { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ApiResponse register( + public ApiResponse register( @RequestBody MemberV1Dto.RegisterRequest request ) { MemberInfo info = memberFacade.register( @@ -30,7 +33,13 @@ public ApiResponse register( request.birthDate(), request.email() ); - MemberV1Dto.RegisterResponse response = MemberV1Dto.RegisterResponse.from(info); - return ApiResponse.success(response); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @GetMapping("/me") + public ApiResponse getMe(HttpServletRequest request) { + Member authenticatedMember = (Member) request.getAttribute("authenticatedMember"); + MemberInfo info = memberFacade.getMe(authenticatedMember); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } } 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 5be7b819..2462f783 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 @@ -14,14 +14,14 @@ public record RegisterRequest( String email ) {} - public record RegisterResponse( + public record MemberResponse( String loginId, String name, LocalDate birthDate, String email ) { - public static RegisterResponse from(MemberInfo info) { - return new RegisterResponse( + public static MemberResponse from(MemberInfo info) { + return new MemberResponse( info.loginId(), info.name(), info.birthDate(), diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java new file mode 100644 index 00000000..1fad9437 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java @@ -0,0 +1,76 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberReader; +import com.loopers.domain.member.PasswordEncoder; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberAuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberReader memberReader; + private final PasswordEncoder passwordEncoder; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // 인증이 필요 없는 경로는 통과 + if (!requiresAuthentication(request)) { + filterChain.doFilter(request, response); + return; + } + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String loginPw = request.getHeader(HEADER_LOGIN_PW); + + // 헤더가 없으면 401 + if (loginId == null || loginPw == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 회원 조회 + Optional memberOpt = memberReader.findByLoginId(loginId); + if (memberOpt.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 비밀번호 검증 + Member member = memberOpt.get(); + if (!passwordEncoder.matches(loginPw, member.getPassword())) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 인증 성공 - 회원 정보를 request에 저장 + request.setAttribute("authenticatedMember", member); + filterChain.doFilter(request, response); + } + + private boolean requiresAuthentication(HttpServletRequest request) { + String path = request.getRequestURI(); + String method = request.getMethod(); + + // POST /api/v1/members (회원가입)는 인증 불필요 + if ("POST".equals(method) && "/api/v1/members".equals(path)) { + return false; + } + + // /api/v1/members/** 경로는 인증 필요 + return path.startsWith("/api/v1/members/"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java index 34b243ff..57ea9a04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java @@ -1,20 +1,27 @@ package com.loopers.support.config; +import com.loopers.support.auth.MemberAuthFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +@RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfig { + private final MemberAuthFilter memberAuthFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .addFilterBefore(memberAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java index 82275c4b..b24151b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -89,6 +90,11 @@ void addExistingLoginId(String loginId) { public boolean existsByLoginId(String loginId) { return existingLoginIds.containsKey(loginId); } + + @Override + public Optional findByLoginId(String loginId) { + return Optional.empty(); + } } static class FakeMemberRepository implements MemberRepository { 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 835d865a..1373a5a0 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,6 +1,7 @@ package com.loopers.interfaces.api; import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.member.MemberV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -11,6 +12,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -24,6 +26,9 @@ class MemberV1ApiE2ETest { private static final String ENDPOINT_REGISTER = "/api/v1/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; private final TestRestTemplate testRestTemplate; private final MemberJpaRepository memberJpaRepository; @@ -53,7 +58,7 @@ class Register { @Test void returnsCreated_whenValidRequest() { // arrange - RegisterRequest request = new RegisterRequest( + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( "testUser1", "Test1234!", "홍길동", @@ -62,8 +67,8 @@ void returnsCreated_whenValidRequest() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = testRestTemplate.exchange( + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), @@ -82,7 +87,7 @@ void returnsCreated_whenValidRequest() { @Test void returnsBadRequest_whenLoginIdAlreadyExists() { // arrange - 먼저 회원가입 - RegisterRequest firstRequest = new RegisterRequest( + MemberV1Dto.RegisterRequest firstRequest = new MemberV1Dto.RegisterRequest( "existingUser", "Test1234!", "홍길동", @@ -93,11 +98,11 @@ void returnsBadRequest_whenLoginIdAlreadyExists() { ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(firstRequest), - new ParameterizedTypeReference>() {} + new ParameterizedTypeReference>() {} ); // arrange - 같은 로그인ID로 다시 가입 시도 - RegisterRequest duplicateRequest = new RegisterRequest( + MemberV1Dto.RegisterRequest duplicateRequest = new MemberV1Dto.RegisterRequest( "existingUser", "Test5678!", "김철수", @@ -106,8 +111,8 @@ void returnsBadRequest_whenLoginIdAlreadyExists() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = testRestTemplate.exchange( + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(duplicateRequest), @@ -122,7 +127,7 @@ void returnsBadRequest_whenLoginIdAlreadyExists() { @Test void returnsBadRequest_whenInvalidEmail() { // arrange - RegisterRequest request = new RegisterRequest( + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( "testUser2", "Test1234!", "홍길동", @@ -131,8 +136,8 @@ void returnsBadRequest_whenInvalidEmail() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = testRestTemplate.exchange( + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), @@ -144,7 +149,105 @@ void returnsBadRequest_whenInvalidEmail() { } } - // 테스트용 Request/Response record (실제 DTO가 없으므로 임시 정의) - record RegisterRequest(String loginId, String password, String name, LocalDate birthDate, String email) {} - record RegisterResponse(String loginId, String name, LocalDate birthDate, String email) {} + @DisplayName("GET /api/v1/members/me (내 정보 조회)") + @Nested + class GetMe { + + @DisplayName("유효한 인증 헤더로 조회하면, 200 OK와 마스킹된 이름을 반환한다.") + @Test + void returnsOk_whenValidAuth() { + // arrange - 먼저 회원가입 + String loginId = "testUser1"; + String password = "Test1234!"; + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + password, + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 인증 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), // 마스킹 확인 + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("인증 헤더가 없으면, 401 Unauthorized 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoAuthHeader() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(null), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("잘못된 비밀번호로 조회하면, 401 Unauthorized 응답을 받는다.") + @Test + void returnsUnauthorized_whenWrongPassword() { + // arrange - 먼저 회원가입 + String loginId = "testUser2"; + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test2@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 잘못된 비밀번호로 인증 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, "WrongPassword1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + } diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http index 90dafde2..9f5a380c 100644 --- a/http/commerce-api/member-v1.http +++ b/http/commerce-api/member-v1.http @@ -47,3 +47,20 @@ Content-Type: application/json "birthDate": "1990-01-15", "email": "test3@example.com" } + +############################################### +# 내 정보 조회 API +############################################### + +### 내 정보 조회 (정상 - 회원가입 먼저 실행 후) +GET {{baseUrl}}/api/v1/members/me +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 정보 조회 (인증 헤더 없음 - 401) +GET {{baseUrl}}/api/v1/members/me + +### 내 정보 조회 (잘못된 비밀번호 - 401) +GET {{baseUrl}}/api/v1/members/me +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: WrongPassword1! From ab309b22b9376a96ebe6cbe86906943e19c2ddb4 Mon Sep 17 00:00:00 2001 From: juoklee Date: Thu, 5 Feb 2026 21:21:14 +0900 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=88=98=EC=A0=95=20API=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 | 4 + .../com/loopers/domain/member/Member.java | 16 ++ .../loopers/domain/member/MemberService.java | 5 + .../api/member/MemberV1Controller.java | 15 ++ .../interfaces/api/member/MemberV1Dto.java | 5 + .../interfaces/api/MemberV1ApiE2ETest.java | 156 ++++++++++++++++++ http/commerce-api/member-v1.http | 48 ++++++ 7 files changed, 249 insertions(+) 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 891b34e7..83ff27ad 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 @@ -22,4 +22,8 @@ public MemberInfo register(String loginId, String rawPassword, String name, public MemberInfo getMe(Member authenticatedMember) { return MemberInfo.fromWithMaskedName(authenticatedMember); } + + public void changePassword(Member authenticatedMember, String currentPassword, String newPassword) { + memberService.changePassword(authenticatedMember, currentPassword, newPassword); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index 77da26dc..2e8d471c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -80,6 +80,22 @@ private static void validateEmail(String email) { } } + public void changePassword(String currentPassword, String newRawPassword, + PasswordEncoder encoder) { + // 현재 비밀번호 확인 + if (!encoder.matches(currentPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + // 새 비밀번호가 현재와 동일한지 확인 + if (encoder.matches(newRawPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + // 새 비밀번호 규칙 검증 + validatePassword(newRawPassword, this.birthDate); + // 비밀번호 변경 + this.password = encoder.encode(newRawPassword); + } + // Getter public String getLoginId() { return loginId; 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 ead31501..7ab5d49a 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 @@ -24,4 +24,9 @@ public Member register(String loginId, String rawPassword, String name, Member member = Member.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); return memberRepository.save(member); } + + public void changePassword(Member member, String currentPassword, String newPassword) { + member.changePassword(currentPassword, newPassword, passwordEncoder); + memberRepository.save(member); + } } 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 149ac19c..3966e84b 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 @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -42,4 +43,18 @@ public ApiResponse getMe(HttpServletRequest request) MemberInfo info = memberFacade.getMe(authenticatedMember); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + HttpServletRequest request, + @RequestBody MemberV1Dto.ChangePasswordRequest passwordRequest + ) { + Member authenticatedMember = (Member) request.getAttribute("authenticatedMember"); + memberFacade.changePassword( + authenticatedMember, + passwordRequest.currentPassword(), + passwordRequest.newPassword() + ); + return ApiResponse.success(null); + } } 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 2462f783..d189e761 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 @@ -14,6 +14,11 @@ public record RegisterRequest( String email ) {} + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + public record MemberResponse( String loginId, String name, 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 1373a5a0..dffbafab 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 @@ -27,6 +27,7 @@ class MemberV1ApiE2ETest { private static final String ENDPOINT_REGISTER = "/api/v1/members"; private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/password"; private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; @@ -250,4 +251,159 @@ void returnsUnauthorized_whenWrongPassword() { } } + @DisplayName("PATCH /api/v1/members/me/password (비밀번호 수정)") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청으로 비밀번호를 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidRequest() { + // arrange - 먼저 회원가입 + String loginId = "testUser1"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass5678!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헤더와 요청 본문 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + newPassword + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert - 비밀번호 변경 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // assert - 새 비밀번호로 인증 가능한지 확인 + HttpHeaders newAuthHeaders = new HttpHeaders(); + newAuthHeaders.set(HEADER_LOGIN_ID, loginId); + newAuthHeaders.set(HEADER_LOGIN_PW, newPassword); + + ResponseEntity> meResponse = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(newAuthHeaders), + new ParameterizedTypeReference<>() {} + ); + assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenCurrentPasswordNotMatch() { + // arrange - 먼저 회원가입 + String loginId = "testUser2"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헤더와 잘못된 현재 비밀번호로 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + "WrongCurrent1!", // 잘못된 현재 비밀번호 + "NewPass5678!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange - 먼저 회원가입 + String loginId = "testUser3"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헤더와 동일한 비밀번호로 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + currentPassword // 현재 비밀번호와 동일 + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNewPasswordInvalid() { + // arrange - 먼저 회원가입 + String loginId = "testUser4"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헤더와 규칙 위반 비밀번호로 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + "short" // 8자 미만 + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private void registerMember(String loginId, String password) { + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + password, + "홍길동", + LocalDate.of(1990, 1, 15), + loginId + "@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + } + } + } diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http index 9f5a380c..d0371357 100644 --- a/http/commerce-api/member-v1.http +++ b/http/commerce-api/member-v1.http @@ -64,3 +64,51 @@ GET {{baseUrl}}/api/v1/members/me GET {{baseUrl}}/api/v1/members/me X-Loopers-LoginId: testUser1 X-Loopers-LoginPw: WrongPassword1! + +############################################### +# 비밀번호 수정 API +############################################### + +### 비밀번호 수정 (정상 - 회원가입 먼저 실행 후) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass5678!" +} + +### 비밀번호 수정 (현재 비밀번호 불일치 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "WrongCurrent1!", + "newPassword": "NewPass5678!" +} + +### 비밀번호 수정 (현재와 동일 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "Test1234!" +} + +### 비밀번호 수정 (규칙 위반 - 너무 짧음 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "short" +} From d1c075790e42ccaf0bbfa26f31327c25db40fc80 Mon Sep 17 00:00:00 2001 From: juoklee Date: Thu, 5 Feb 2026 22:17:55 +0900 Subject: [PATCH 16/20] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/MemberTest.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index c4d57f9d..5ea7527f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -237,4 +237,102 @@ void throwsBadRequest_whenEmailFormatIsInvalid() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("비밀번호 변경 시, ") + @Nested + class ChangePassword { + + private Member createMember() { + return Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { + // Arrange + Member member = createMember(); + String wrongCurrentPassword = "WrongPass1!"; + String newPassword = "NewPass5678!"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(wrongCurrentPassword, newPassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("현재 비밀번호가 일치하지 않습니다."); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String samePassword = "Test1234!"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, samePassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordViolatesRules() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String shortPassword = "short"; // 8자 미만 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, shortPassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordContainsBirthDate() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String passwordWithBirthDate = "Pass19900115!"; // 생년월일 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, passwordWithBirthDate, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("비밀번호에 생년월일을 포함할 수 없습니다."); + } + + @DisplayName("모든 조건이 유효하면, 비밀번호가 정상적으로 변경된다.") + @Test + void changesPassword_whenAllConditionsAreValid() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String newPassword = "NewPass5678!"; + + // Act + member.changePassword(currentPassword, newPassword, stubEncoder); + + // Assert + assertThat(member.getPassword()).isEqualTo("encoded_" + newPassword); + } + } } From 2730d672bbe0aa5e10a9bc53a0b329846e3edabc Mon Sep 17 00:00:00 2001 From: juoklee Date: Fri, 6 Feb 2026 18:32:06 +0900 Subject: [PATCH 17/20] =?UTF-8?q?refactor:=20Member=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20null=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberV1Controller: getAuthenticatedMember() 헬퍼 메서드 추가로 null 체크 - MemberInfo: maskName()에서 null 입력 시 빈 문자열 반환 - MemberAuthFilter: Optional.get() 대신 filter().orElse(null) 패턴 적용 - Member: create(), changePassword()에 null/blank guard clause 추가 - MemberTest: 필수값 검증 테스트 8개 추가 --- .../application/member/MemberInfo.java | 2 +- .../com/loopers/domain/member/Member.java | 21 ++++ .../api/member/MemberV1Controller.java | 12 +- .../support/auth/MemberAuthFilter.java | 15 +-- .../com/loopers/domain/member/MemberTest.java | 103 ++++++++++++++++++ 5 files changed, 140 insertions(+), 13 deletions(-) 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 dbee777e..d6078e7a 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 @@ -25,7 +25,7 @@ public static MemberInfo fromWithMaskedName(Member member) { private static String maskName(String name) { if (name == null || name.isEmpty()) { - return name; + return ""; } if (name.length() == 1) { return "*"; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index 2e8d471c..b45624ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -32,6 +32,12 @@ private Member(String loginId, String password, String name, public static Member create(String loginId, String rawPassword, String name, LocalDate birthDate, String email, PasswordEncoder encoder) { + validateNotBlank(loginId, "로그인ID는 필수입니다."); + validateNotBlank(rawPassword, "비밀번호는 필수입니다."); + validateNotBlank(name, "이름은 필수입니다."); + validateNotNull(birthDate, "생년월일은 필수입니다."); + validateNotBlank(email, "이메일은 필수입니다."); + validateLoginId(loginId); validatePassword(rawPassword, birthDate); String normalizedName = normalizeName(name); @@ -42,6 +48,18 @@ public static Member create(String loginId, String rawPassword, return new Member(loginId, encodedPassword, normalizedName, birthDate, email); } + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + private static void validatePassword(String password, LocalDate birthDate) { if (password.length() < 8 || password.length() > 16) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); @@ -82,6 +100,9 @@ private static void validateEmail(String email) { public void changePassword(String currentPassword, String newRawPassword, PasswordEncoder encoder) { + validateNotBlank(currentPassword, "현재 비밀번호는 필수입니다."); + validateNotBlank(newRawPassword, "새 비밀번호는 필수입니다."); + // 현재 비밀번호 확인 if (!encoder.matches(currentPassword, this.password)) { throw new CoreException(ErrorType.BAD_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 3966e84b..daa8b42f 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 @@ -39,7 +39,7 @@ public ApiResponse register( @GetMapping("/me") public ApiResponse getMe(HttpServletRequest request) { - Member authenticatedMember = (Member) request.getAttribute("authenticatedMember"); + Member authenticatedMember = getAuthenticatedMember(request); MemberInfo info = memberFacade.getMe(authenticatedMember); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } @@ -49,7 +49,7 @@ public ApiResponse changePassword( HttpServletRequest request, @RequestBody MemberV1Dto.ChangePasswordRequest passwordRequest ) { - Member authenticatedMember = (Member) request.getAttribute("authenticatedMember"); + Member authenticatedMember = getAuthenticatedMember(request); memberFacade.changePassword( authenticatedMember, passwordRequest.currentPassword(), @@ -57,4 +57,12 @@ public ApiResponse changePassword( ); return ApiResponse.success(null); } + + private Member getAuthenticatedMember(HttpServletRequest request) { + Object attribute = request.getAttribute("authenticatedMember"); + if (attribute == null) { + throw new IllegalStateException("인증된 회원 정보가 없습니다."); + } + return (Member) attribute; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java index 1fad9437..a1e7cfb4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java @@ -12,7 +12,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Optional; @RequiredArgsConstructor @Component @@ -42,16 +41,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - // 회원 조회 - Optional memberOpt = memberReader.findByLoginId(loginId); - if (memberOpt.isEmpty()) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } + // 회원 조회 및 비밀번호 검증 + Member member = memberReader.findByLoginId(loginId) + .filter(m -> passwordEncoder.matches(loginPw, m.getPassword())) + .orElse(null); - // 비밀번호 검증 - Member member = memberOpt.get(); - if (!passwordEncoder.matches(loginPw, member.getPassword())) { + if (member == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 5ea7527f..12c0b652 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -27,6 +27,77 @@ public boolean matches(String rawPassword, String encodedPassword) { } }; + @DisplayName("필수값 검증 시, ") + @Nested + class ValidateRequired { + + @DisplayName("로그인ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create(null, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("로그인ID는 필수입니다."); + } + + @DisplayName("로그인ID가 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsBlank() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create(" ", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("로그인ID는 필수입니다."); + } + + @DisplayName("비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", null, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("비밀번호는 필수입니다."); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", null, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("이름은 필수입니다."); + } + + @DisplayName("생년월일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthDateIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", "홍길동", + null, "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("생년월일은 필수입니다."); + } + + @DisplayName("이메일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), null, stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("이메일은 필수입니다."); + } + } + @DisplayName("회원을 생성할 때, ") @Nested class Create { @@ -249,6 +320,38 @@ private Member createMember() { ); } + @DisplayName("현재 비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCurrentPasswordIsNull() { + // Arrange + Member member = createMember(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(null, "NewPass5678!", stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("현재 비밀번호는 필수입니다."); + } + + @DisplayName("새 비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsNull() { + // Arrange + Member member = createMember(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword("Test1234!", null, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("새 비밀번호는 필수입니다."); + } + @DisplayName("현재 비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { From 235c0d5c73649fcb6eb29ba325abf91610f1632b Mon Sep 17 00:00:00 2001 From: juoklee Date: Fri, 6 Feb 2026 18:32:22 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20Response=20DTO=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExampleV1Dto: 중첩 구조를 flat 구조로 변경 - MemberV1ApiE2ETest: 중첩 DTO 접근자 .member() 추가 --- .../interfaces/api/member/MemberV1Dto.java | 22 ++++++++++++------- .../interfaces/api/MemberV1ApiE2ETest.java | 8 +++---- 2 files changed, 18 insertions(+), 12 deletions(-) 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 d189e761..9536c4d9 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 @@ -20,17 +20,23 @@ public record ChangePasswordRequest( ) {} public record MemberResponse( - String loginId, - String name, - LocalDate birthDate, - String email + MemberDto member ) { + public record MemberDto( + String loginId, + String name, + LocalDate birthDate, + String email + ) {} + public static MemberResponse from(MemberInfo info) { return new MemberResponse( - info.loginId(), - info.name(), - info.birthDate(), - info.email() + new MemberDto( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ) ); } } 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 dffbafab..b1a7bc99 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 @@ -79,7 +79,7 @@ void returnsCreated_whenValidRequest() { // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), - () -> assertThat(response.getBody().data().loginId()).isEqualTo("testUser1"), + () -> assertThat(response.getBody().data().member().loginId()).isEqualTo("testUser1"), () -> assertThat(memberJpaRepository.existsByLoginId("testUser1")).isTrue() ); } @@ -191,9 +191,9 @@ void returnsOk_whenValidAuth() { // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId), - () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), // 마스킹 확인 - () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + () -> assertThat(response.getBody().data().member().loginId()).isEqualTo(loginId), + () -> assertThat(response.getBody().data().member().name()).isEqualTo("홍길*"), // 마스킹 확인 + () -> assertThat(response.getBody().data().member().email()).isEqualTo("test@example.com") ); } From 9f6574dac18585dae3d4f68d8f426c60fc595324 Mon Sep 17 00:00:00 2001 From: juoklee Date: Fri, 6 Feb 2026 19:26:56 +0900 Subject: [PATCH 19/20] =?UTF-8?q?feat:=20Member.loginId=EC=97=90=20unique?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 인덱스 생성으로 existsByLoginId/findByLoginId 쿼리 성능 개선 --- .../src/main/java/com/loopers/domain/member/Member.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index b45624ce..70d1d50e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -3,6 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -12,6 +13,7 @@ @Table(name = "member") public class Member extends BaseEntity { + @Column(unique = true, nullable = false) private String loginId; private String password; private String name; From 81539391bd8f77ab5791972d2e134c335ad8b6eb Mon Sep 17 00:00:00 2001 From: juoklee Date: Thu, 12 Feb 2026 23:30:12 +0900 Subject: [PATCH 20/20] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20401=20=ED=91=9C=EC=A4=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/member/MemberV1Controller.java | 8 ++-- .../support/auth/MemberAuthFilter.java | 18 +++++++- .../com/loopers/support/error/ErrorType.java | 1 + .../interfaces/api/MemberV1ApiE2ETest.java | 44 +++++++++++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) 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 daa8b42f..4e811dfd 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 @@ -4,6 +4,8 @@ import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.Member; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -60,9 +62,9 @@ public ApiResponse changePassword( private Member getAuthenticatedMember(HttpServletRequest request) { Object attribute = request.getAttribute("authenticatedMember"); - if (attribute == null) { - throw new IllegalStateException("인증된 회원 정보가 없습니다."); + if (!(attribute instanceof Member member)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증된 회원 정보가 없습니다."); } - return (Member) attribute; + return member; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java index a1e7cfb4..5d029739 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java @@ -1,13 +1,17 @@ package com.loopers.support.auth; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberReader; import com.loopers.domain.member.PasswordEncoder; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.ErrorType; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -22,6 +26,7 @@ public class MemberAuthFilter extends OncePerRequestFilter { private final MemberReader memberReader; private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -37,7 +42,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 헤더가 없으면 401 if (loginId == null || loginPw == null) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + sendUnauthorizedResponse(response); return; } @@ -47,7 +52,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .orElse(null); if (member == null) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + sendUnauthorizedResponse(response); return; } @@ -56,6 +61,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } + private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException { + ErrorType errorType = ErrorType.UNAUTHORIZED; + response.setStatus(errorType.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + ApiResponse body = ApiResponse.fail(errorType.getCode(), errorType.getMessage()); + objectMapper.writeValue(response.getWriter(), body); + } + private boolean requiresAuthentication(HttpServletRequest request) { String path = request.getRequestURI(); String method = request.getMethod(); 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..bc9fb4c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index b1a7bc99..6e38b464 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 @@ -197,7 +197,7 @@ void returnsOk_whenValidAuth() { ); } - @DisplayName("인증 헤더가 없으면, 401 Unauthorized 응답을 받는다.") + @DisplayName("인증 헤더가 없으면, 401 Unauthorized와 표준 에러 바디를 반환한다.") @Test void returnsUnauthorized_whenNoAuthHeader() { // act @@ -210,10 +210,15 @@ void returnsUnauthorized_whenNoAuthHeader() { ); // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo("Unauthorized") + ); } - @DisplayName("잘못된 비밀번호로 조회하면, 401 Unauthorized 응답을 받는다.") + @DisplayName("잘못된 비밀번호로 조회하면, 401 Unauthorized와 표준 에러 바디를 반환한다.") @Test void returnsUnauthorized_whenWrongPassword() { // arrange - 먼저 회원가입 @@ -247,7 +252,12 @@ void returnsUnauthorized_whenWrongPassword() { ); // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo("Unauthorized") + ); } } @@ -389,6 +399,32 @@ void returnsBadRequest_whenNewPasswordInvalid() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } + @DisplayName("인증 헤더가 없으면, 401 Unauthorized와 표준 에러 바디를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuthHeader() { + // arrange + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + "Test1234!", + "NewPass5678!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo("Unauthorized") + ); + } + private void registerMember(String loginId, String password) { MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( loginId,