From c7033a10d0c668a9e5b1e2d54aa6231eec439c87 Mon Sep 17 00:00:00 2001 From: iohyeon Date: Wed, 4 Feb 2026 02:23:26 +0900 Subject: [PATCH 01/44] =?UTF-8?q?test:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/LoginId.java | 22 ++ .../com/loopers/domain/user/LoginIdTest.java | 197 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java new file mode 100644 index 00000000..733f43f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -0,0 +1,22 @@ +package com.loopers.domain.user; + +import jakarta.persistence.Embeddable; + +@Embeddable +public class LoginId { + private String value ; + + // 기본 생성자 (protected) + protected LoginId() {} + + // 매개변수 생성자 (단순 할당만 - 껍데기) + public LoginId(String value) { + this.value = value; + } + + // getter + public String getValue() { + return value; + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java new file mode 100644 index 00000000..e4e4be34 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -0,0 +1,197 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + - * Red Phase + - * 1. 먼저 유효한 ID 생성 성공 테스트 작성 Happy Case + - * 2. 테스트 실행 → 컴파일 에러 확인 (LoginId 클래스 없음) + - * 3. Red 상태를 진행해봄! - 컴파일 에러 발생 근데 일단 이 컴파일 에러는 TDD에서의 시작이라 + - * 시작이 중요한게 아니라 테스트 케이스를 작성한 다음에 메인 코드가 작성이 잘되는게 테스트 코드 작성의 꽃이다. + - * 테스트 케이스를 작성하다보면 너무 많아지는데 이 상황에서는 AB CD DE ... 무수히 많은 테스트 코드 + - * 그래서 깔끔하고 응집도 높게 집중할 수 있는 테스트 케이스를 작성하고 검증할 수 있는 테스트 케이스를 작성하면 객체 지향을 준수한 + - * 메인 테스트 코드를 만들 수 있다고 생각한다. 테스트 코드의 장점이다. + - * 테스트 커버리지ㄴ를 올리기보단 .. + - * 객체지향적 프로그래밍을 고려해서 테스트 코드를 진행하고싶음 + + * LoginId Value Object 단위 테스트 + * + - * 스순환참조가 발생될 수 있는데 서비스간 함수를 두자 상위 레이어로 파사드란 레이어를 위치한다. + - * A 파사드는 A 서비스를 호출하면서도 의존하면서도 서비스를 의존받을 수 있다. + - * 파사드 패턴이란 무엇일가 + - * + - * 테스트 코드를 작성하기 이전에 설계부터 진행하는게 맞다고 생각해서 일다 나는 설계부터 진행 + - * + - * 검증 로직은 아직 넣지 않음 + - * + - * 껍데기 단계에서는 단순 할당만 수행 + - * → 테스트 작성 후 Red 상태 확인 + - * → 그 다음 검증 로직 추가 (Green) + + +/** + * LoginId Value Object 단위 테스트 + * + * 검증 규칙: + * - 영문 대소문자 + 숫자만 허용 + * - 4~20자 + * - 영문으로 시작 + */ +public class LoginIdTest { + + @DisplayName("로그인 ID를 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @DisplayName("L-S01: 최소 길이(4자) 영문이면, 정상적으로 생성된다.") + @Test + void createsLoginId_whenMinLength() { + // arrange + String value = "nahyeon"; + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + @DisplayName("L-S02: 최대 길이(20자)이면, 정상적으로 생성된다.") + @Test + void createsLoginId_whenMaxLength() { + // arrange + String value = "abcdefghij1234567890"; // 20자 + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + @DisplayName("L-S03: 영문 대소문자 + 숫자 조합이면, 정상적으로 생성된다.") + @Test + void createsLoginId_whenAlphanumeric() { + // arrange + String value = "nahyeon123"; + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + // ========== 엣지 케이스 ========== + + @DisplayName("L-E01: null이면, 예외가 발생한다.") + @Test + void throwsException_whenNull() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E02: 빈 문자열이면, 예외가 발생한다.") + @Test + void throwsException_whenEmpty() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(""); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E03: 공백만 있으면, 예외가 발생한다.") + @Test + void throwsException_whenBlank() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(" "); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E04: 3자(최소 미만)이면, 예외가 발생한다.") + @Test + void throwsException_whenLessThanMinLength() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("abc"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E05: 21자(최대 초과)이면, 예외가 발생한다.") + @Test + void throwsException_whenExceedsMaxLength() { + // arrange + String value = "abcdefghij12345678901"; // 21자 + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E06: 특수문자가 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpecialCharacter() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon@123"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E07: 한글이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsKorean() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon홍"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E08: 숫자로 시작하면, 예외가 발생한다.") + @Test + void throwsException_whenStartsWithNumber() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("123nahyeon"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("L-E09: 공백이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpace() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon Lim"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} From a72ca5d015dbe545dd44a618cef0fef56087337d Mon Sep 17 00:00:00 2001 From: iohyeon Date: Wed, 4 Feb 2026 02:30:54 +0900 Subject: [PATCH 02/44] =?UTF-8?q?test:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/LoginId.java | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java index 733f43f6..6f682212 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -1,22 +1,55 @@ package com.loopers.domain.user; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import java.util.regex.Pattern; + +/** + * 로그인 ID Value Object + * + * 검증 규칙: + * - 영문 대소문자 + 숫자만 허용 + * - 4~20자 + * - 영문으로 시작 + */ @Embeddable public class LoginId { - private String value ; - // 기본 생성자 (protected) + private static final int MIN_LENGTH = 4; + private static final int MAX_LENGTH = 20; + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9]{3,19}$"); + + @Column(name = "login_id") + private String value; + + // JPA 기본 생성자 protected LoginId() {} - // 매개변수 생성자 (단순 할당만 - 껍데기) public LoginId(String value) { + validate(value); this.value = value; } - // getter + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + "로그인 ID는 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + } + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "로그인 ID는 영문으로 시작하고, 영문과 숫자만 사용할 수 있습니다."); + } + } + public String getValue() { return value; } - } From 34f39188f63155e6bab8d932fcbc584d5857d115 Mon Sep 17 00:00:00 2001 From: iohyeon Date: Wed, 4 Feb 2026 23:56:28 +0900 Subject: [PATCH 03/44] =?UTF-8?q?test:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=A8=EC=8A=A4=EC=9B=8C=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=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 --- apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/domain/user/Password.java | 144 ++++++++++ .../com/loopers/domain/user/PasswordTest.java | 260 ++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..db169be0 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + + // security (BCrypt) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 00000000..49b8d706 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,144 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +/** + * 비밀번호 Value Object + * + * 검증 규칙: + * - 8~16자 + * - 영문 대소문자, 숫자, 특수문자만 허용 (공백, 한글 등 불가) + * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 + * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) + * - 동일 문자 3회 이상 연속 금지 (대소문자 구분 없음) + * - 연속된 문자/숫자 3자리 이상 금지 (abc, 123 등) + */ +@Embeddable +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final int MIN_COMPLEXITY = 3; + private static final int CONSECUTIVE_LIMIT = 3; + private static final Pattern ALLOWED_CHARS = Pattern.compile("^[!-~]+$"); + private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); + + @Column(name = "password") + private String encodedValue; + + protected Password() {} + + private Password(String encodedValue) { + this.encodedValue = encodedValue; + } + + public static Password of(String rawPassword, LocalDate birthDate) { + validate(rawPassword, birthDate); + return new Password(ENCODER.encode(rawPassword)); + } + + public boolean matches(String rawPassword) { + return ENCODER.matches(rawPassword, this.encodedValue); + } + + private static void validate(String rawPassword, LocalDate birthDate) { + validateNotBlank(rawPassword); + validateLength(rawPassword); + validateAllowedChars(rawPassword); + validateComplexity(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + validateNoConsecutiveSameChars(rawPassword); + validateNoSequentialChars(rawPassword); + } + + private static void validateNotBlank(String rawPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + } + + private static void validateLength(String rawPassword) { + if (rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + } + } + + private static void validateAllowedChars(String rawPassword) { + if (!ALLOWED_CHARS.matcher(rawPassword).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용할 수 있습니다."); + } + } + + private static void validateComplexity(String rawPassword) { + int typeCount = 0; + if (rawPassword.chars().anyMatch(Character::isUpperCase)) typeCount++; + if (rawPassword.chars().anyMatch(Character::isLowerCase)) typeCount++; + if (rawPassword.chars().anyMatch(Character::isDigit)) typeCount++; + if (rawPassword.chars().anyMatch(c -> !Character.isLetterOrDigit(c))) typeCount++; + + if (typeCount < MIN_COMPLEXITY) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 영문 대문자, 소문자, 숫자, 특수문자 중 " + MIN_COMPLEXITY + "종류 이상 포함해야 합니다."); + } + } + + private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String yymmdd = yyyymmdd.substring(2); + String mmdd = yyyymmdd.substring(4); + + if (rawPassword.contains(yyyymmdd) || rawPassword.contains(yymmdd) || rawPassword.contains(mmdd)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + private static void validateNoConsecutiveSameChars(String rawPassword) { + String lower = rawPassword.toLowerCase(); + for (int i = 0; i <= lower.length() - CONSECUTIVE_LIMIT; i++) { + char target = lower.charAt(i); + boolean allSame = true; + for (int j = 1; j < CONSECUTIVE_LIMIT; j++) { + if (lower.charAt(i + j) != target) { + allSame = false; + break; + } + } + if (allSame) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호에 동일 문자를 " + CONSECUTIVE_LIMIT + "회 이상 연속 사용할 수 없습니다."); + } + } + } + + private static void validateNoSequentialChars(String rawPassword) { + String lower = rawPassword.toLowerCase(); + for (int i = 0; i <= lower.length() - CONSECUTIVE_LIMIT; i++) { + char c1 = lower.charAt(i); + char c2 = lower.charAt(i + 1); + char c3 = lower.charAt(i + 2); + + boolean sameType = (Character.isLetter(c1) && Character.isLetter(c2) && Character.isLetter(c3)) + || (Character.isDigit(c1) && Character.isDigit(c2) && Character.isDigit(c3)); + + if (sameType) { + boolean ascending = (c2 - c1 == 1) && (c3 - c2 == 1); + boolean descending = (c1 - c2 == 1) && (c2 - c3 == 1); + if (ascending || descending) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호에 연속된 문자 또는 숫자를 " + CONSECUTIVE_LIMIT + "자리 이상 사용할 수 없습니다."); + } + } + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 00000000..d8880555 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,260 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Password Value Object 단위 테스트 + * + * 검증 규칙: + * - 8~16자 + * - 영문 대소문자, 숫자, 특수문자만 허용 + * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 + * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) + * - 동일 문자 3회 이상 연속 금지 + * - 연속된 문자/숫자 3자리 이상 금지 + */ +public class PasswordTest { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1994, 11, 15); + + @DisplayName("비밀번호를 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @DisplayName("모든 규칙을 만족하면, 정상적으로 생성된다.") + @Test + void createsPassword_whenAllRulesSatisfied() { + // arrange + String rawPassword = "Hx7!mK2@"; + + // act + Password password = Password.of(rawPassword, BIRTH_DATE); + + // assert + assertThat(password.matches(rawPassword)).isTrue(); + } + + @DisplayName("최소 길이(8자)이면, 정상적으로 생성된다.") + @Test + void createsPassword_whenMinLength() { + // arrange + String rawPassword = "Xz5!qw9@"; // 8자 + + // act + Password password = Password.of(rawPassword, BIRTH_DATE); + + // assert + assertThat(password.matches(rawPassword)).isTrue(); + } + + @DisplayName("최대 길이(16자)이면, 정상적으로 생성된다.") + @Test + void createsPassword_whenMaxLength() { + // arrange + String rawPassword = "Px8!Kd3@Wm7#Rf2$"; // 16자 + + // act + Password password = Password.of(rawPassword, BIRTH_DATE); + + // assert + assertThat(password.matches(rawPassword)).isTrue(); + } + + @DisplayName("다양한 특수문자 조합이면, 정상적으로 생성된다.") + @Test + void createsPassword_whenVariousSpecialChars() { + // arrange + String rawPassword = "Ac1~`[]{}"; + + // act + Password password = Password.of(rawPassword, BIRTH_DATE); + + // assert + assertThat(password.matches(rawPassword)).isTrue(); + } + + // ========== 엣지 케이스 ========== + + @DisplayName("null이면, 예외가 발생한다.") + @Test + void throwsException_whenNull() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of(null, BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, 예외가 발생한다.") + @Test + void throwsException_whenEmpty() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("7자(최소 미만)이면, 예외가 발생한다.") + @Test + void throwsException_whenLessThanMinLength() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abcd12!", BIRTH_DATE); // 7자 + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("17자(최대 초과)이면, 예외가 발생한다.") + @Test + void throwsException_whenExceedsMaxLength() { + // arrange + String rawPassword = "Px8!Kd3@Wm7#Rf2$A"; // 17자 + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of(rawPassword, BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문만 있으면(복잡도 미달), 예외가 발생한다.") + @Test + void throwsException_whenOnlyLetters() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abcdefgh", BIRTH_DATE); // 대문자+소문자 = 2종 + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("숫자만 있으면(복잡도 미달), 예외가 발생한다.") + @Test + void throwsException_whenOnlyDigits() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("12345978", BIRTH_DATE); // 숫자 = 1종 + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("특수문자만 있으면(복잡도 미달), 예외가 발생한다.") + @Test + void throwsException_whenOnlySpecialChars() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("!@#$%^&*", BIRTH_DATE); // 특수문자 = 1종 + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsKorean() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abcd123가", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("공백이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpace() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abcd 12!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일(YYYYMMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateYYYYMMDD() { + // arrange - birthDate: 1994-11-15 + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("A19941115!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일(MMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateMMDD() { + // arrange - birthDate: 1994-11-15 + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abcd0115!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일(YYMMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateYYMMDD() { + // arrange - birthDate: 1994-11-15 + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("A941115!a", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("동일 문자가 3회 이상 연속되면, 예외가 발생한다.") + @Test + void throwsException_whenThreeConsecutiveSameChars() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Aaab123!@", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("연속 숫자 3자리가 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenThreeSequentialDigits() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("Abzx1234!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("연속 문자 3자리가 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenThreeSequentialChars() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + Password.of("xAbcz12!@", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} From e83aa322ef6b5c3aa7aa01656bdd112b931cd8b4 Mon Sep 17 00:00:00 2001 From: iohyeon Date: Wed, 4 Feb 2026 23:57:17 +0900 Subject: [PATCH 04/44] =?UTF-8?q?test:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A6=84,=20=EC=9D=B4=EB=A9=94=EC=9D=BC,?= =?UTF-8?q?=20=EC=83=9D=EC=9D=BC=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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/user/BirthDate.java | 73 ++++++ .../java/com/loopers/domain/user/Email.java | 67 ++++++ .../com/loopers/domain/user/UserName.java | 61 +++++ .../loopers/domain/user/BirthDateTest.java | 177 ++++++++++++++ .../com/loopers/domain/user/EmailTest.java | 173 ++++++++++++++ .../com/loopers/domain/user/LoginIdTest.java | 24 +- .../com/loopers/domain/user/UserNameTest.java | 222 ++++++++++++++++++ 7 files changed, 785 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java new file mode 100644 index 00000000..564e287d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -0,0 +1,73 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.Period; +import java.time.format.DateTimeParseException; + +/** + * 생년월일 Value Object + * + * 검증 규칙: + * - YYYY-MM-DD 형식 (ISO 8601) + * - 1900-01-01 ~ 현재 날짜 + * - 실제 존재하는 날짜 + * - 만 14세 이상 + */ +@Embeddable +public class BirthDate { + + private static final LocalDate MIN_DATE = LocalDate.of(1900, 1, 1); + private static final int MIN_AGE = 14; + + @Column(name = "birth_date") + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(String rawValue) { + validateNotBlank(rawValue); + this.value = parseDate(rawValue); + validateRange(this.value); + } + + public LocalDate getValue() { + return value; + } + + private static void validateNotBlank(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + } + + private static LocalDate parseDate(String rawValue) { + try { + return LocalDate.parse(rawValue); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 YYYY-MM-DD 형식이어야 합니다."); + } + } + + private static void validateRange(LocalDate date) { + if (date.isBefore(MIN_DATE)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 " + MIN_DATE + " 이후여야 합니다."); + } + + if (date.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 미래 날짜일 수 없습니다."); + } + + if (Period.between(date, LocalDate.now()).getYears() < MIN_AGE) { + throw new CoreException(ErrorType.BAD_REQUEST, + "만 " + MIN_AGE + "세 이상만 가입할 수 있습니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java new file mode 100644 index 00000000..94879b0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,67 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +/** + * 이메일 Value Object + * + * 검증 규칙: + * - RFC 5322 표준 이메일 형식 + * - 최대 255자 + * - 한글/비ASCII 문자 불허 + * - 공백 불허 + * - 로컬 파트 연속 점 불허 + */ +@Embeddable +public class Email { + + private static final int MAX_LENGTH = 255; + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$" + ); + + @Column(name = "email") + private String value; + + protected Email() {} + + public Email(String value) { + validate(value); + this.value = value; + } + + public String getValue() { + return value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + + if (value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이메일은 최대 " + MAX_LENGTH + "자까지 가능합니다."); + } + + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이메일 형식이 올바르지 않습니다."); + } + + validateNoConsecutiveDots(value); + } + + private void validateNoConsecutiveDots(String value) { + String localPart = value.substring(0, value.indexOf('@')); + if (localPart.contains("..")) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이메일 로컬 파트에 연속된 점(.)을 사용할 수 없습니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java new file mode 100644 index 00000000..ab5c3926 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java @@ -0,0 +1,61 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +/** + * 이름 Value Object + * + * 검증 규칙: + * - 한글, 영문만 허용 + * - 2~50자 + * - 공백 불허 + * + * 마스킹 규칙: + * - 마지막 1글자를 '*'로 대체 + */ +@Embeddable +public class UserName { + + private static final int MIN_LENGTH = 2; + private static final int MAX_LENGTH = 50; + private static final Pattern PATTERN = Pattern.compile("^[가-힣a-zA-Z]+$"); + + @Column(name = "name") + private String value; + + protected UserName() {} + + public UserName(String value) { + validate(value); + this.value = value; + } + + public String getValue() { + return value; + } + + public String getMaskedValue() { + return value.substring(0, value.length() - 1) + "*"; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이름은 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + } + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이름은 한글과 영문만 사용할 수 있습니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java new file mode 100644 index 00000000..06837b47 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -0,0 +1,177 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * BirthDate Value Object 단위 테스트 + * + * 검증 규칙: + * - YYYY-MM-DD 형식 (ISO 8601) + * - 1900-01-01 ~ 현재 날짜 + * - 실제 존재하는 날짜 + * - 만 14세 이상 + */ +public class BirthDateTest { + + @DisplayName("생년월일을 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @DisplayName("유효한 날짜이면, 정상적으로 생성된다.") + @Test + void createsBirthDate_whenValid() { + // arrange + String value = "1994-11-15"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1994, 11, 15)); + } + + @DisplayName("윤년 2월 29일이면, 정상적으로 생성된다.") + @Test + void createsBirthDate_whenLeapYear() { + // arrange + String value = "2000-02-29"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(2000, 2, 29)); + } + + @DisplayName("최소 허용 날짜(1900-01-01)이면, 정상적으로 생성된다.") + @Test + void createsBirthDate_whenMinDate() { + // arrange + String value = "1900-01-01"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1900, 1, 1)); + } + + @DisplayName("만 14세 경계(정확히 14세)이면, 정상적으로 생성된다.") + @Test + void createsBirthDate_whenExactlyMinAge() { + // arrange + String value = LocalDate.now().minusYears(14).format(DateTimeFormatter.ISO_LOCAL_DATE); + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.now().minusYears(14)); + } + + // ========== 엣지 케이스 ========== + + @DisplayName("null이면, 예외가 발생한다.") + @Test + void throwsException_whenNull() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("잘못된 형식(슬래시)이면, 예외가 발생한다.") + @Test + void throwsException_whenSlashFormat() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("잘못된 형식(구분자 없음)이면, 예외가 발생한다.") + @Test + void throwsException_whenNoSeparator() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("19941115"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 날짜(2월 30일)이면, 예외가 발생한다.") + @Test + void throwsException_whenInvalidDate() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994-02-30"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("미래 날짜이면, 예외가 발생한다.") + @Test + void throwsException_whenFutureDate() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("2030-01-01"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("1900년 이전이면, 예외가 발생한다.") + @Test + void throwsException_whenTooOld() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1899-12-31"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만 14세 미만이면, 예외가 발생한다.") + @Test + void throwsException_whenUnderMinAge() { + // arrange + String value = LocalDate.now().minusYears(13).format(DateTimeFormatter.ISO_LOCAL_DATE); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비윤년 2월 29일이면, 예외가 발생한다.") + @Test + void throwsException_whenNonLeapYearFeb29() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1999-02-29"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 00000000..07bf53e9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,173 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Email Value Object 단위 테스트 + * + * 검증 규칙: + * - RFC 5322 표준 이메일 형식 + * - 최대 255자 + * - 한글 불허 + * - 공백 불허 + * - 연속 점 불허 (로컬 파트) + */ +public class EmailTest { + + @DisplayName("이메일을 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @DisplayName("유효한 이메일이면, 정상적으로 생성된다.") + @Test + void createsEmail_whenValid() { + // arrange + String value = "nahyeon@example.com"; + + // act + Email email = new Email(value); + + // assert + assertThat(email.getValue()).isEqualTo(value); + } + + @DisplayName("서브도메인이 있으면, 정상적으로 생성된다.") + @Test + void createsEmail_whenSubdomain() { + // arrange + String value = "nahyeon@mail.example.com"; + + // act + Email email = new Email(value); + + // assert + assertThat(email.getValue()).isEqualTo(value); + } + + @DisplayName("+ 기호가 포함되면, 정상적으로 생성된다.") + @Test + void createsEmail_whenPlusSign() { + // arrange + String value = "nahyeon+tag@example.com"; + + // act + Email email = new Email(value); + + // assert + assertThat(email.getValue()).isEqualTo(value); + } + + // ========== 엣지 케이스 ========== + + @DisplayName("null이면, 예외가 발생한다.") + @Test + void throwsException_whenNull() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, 예외가 발생한다.") + @Test + void throwsException_whenEmpty() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email(""); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("@가 없으면, 예외가 발생한다.") + @Test + void throwsException_whenNoAtSign() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("nahyeonexample.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("도메인이 없으면, 예외가 발생한다.") + @Test + void throwsException_whenNoDomain() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("nahyeon@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("로컬 파트가 없으면, 예외가 발생한다.") + @Test + void throwsException_whenNoLocalPart() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("연속 점이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenConsecutiveDots() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("nahyeon..lim@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("공백이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpace() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("nahyeon lim@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("256자를 초과하면, 예외가 발생한다.") + @Test + void throwsException_whenExceedsMaxLength() { + // arrange + String value = "a".repeat(250) + "@b.com"; // 256자 + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsKorean() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new Email("홍길동@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index e4e4be34..31ee06a7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -51,7 +51,7 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("L-S01: 최소 길이(4자) 영문이면, 정상적으로 생성된다.") + @DisplayName("최소 길이(4자) 영문이면, 정상적으로 생성된다.") @Test void createsLoginId_whenMinLength() { // arrange @@ -64,7 +64,7 @@ void createsLoginId_whenMinLength() { assertThat(loginId.getValue()).isEqualTo(value); } - @DisplayName("L-S02: 최대 길이(20자)이면, 정상적으로 생성된다.") + @DisplayName("최대 길이(20자)이면, 정상적으로 생성된다.") @Test void createsLoginId_whenMaxLength() { // arrange @@ -77,7 +77,7 @@ void createsLoginId_whenMaxLength() { assertThat(loginId.getValue()).isEqualTo(value); } - @DisplayName("L-S03: 영문 대소문자 + 숫자 조합이면, 정상적으로 생성된다.") + @DisplayName("영문 대소문자 + 숫자 조합이면, 정상적으로 생성된다.") @Test void createsLoginId_whenAlphanumeric() { // arrange @@ -92,7 +92,7 @@ void createsLoginId_whenAlphanumeric() { // ========== 엣지 케이스 ========== - @DisplayName("L-E01: null이면, 예외가 발생한다.") + @DisplayName("null이면, 예외가 발생한다.") @Test void throwsException_whenNull() { // act & assert @@ -103,7 +103,7 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E02: 빈 문자열이면, 예외가 발생한다.") + @DisplayName("빈 문자열이면, 예외가 발생한다.") @Test void throwsException_whenEmpty() { // act & assert @@ -114,7 +114,7 @@ void throwsException_whenEmpty() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E03: 공백만 있으면, 예외가 발생한다.") + @DisplayName("공백만 있으면, 예외가 발생한다.") @Test void throwsException_whenBlank() { // act & assert @@ -125,7 +125,7 @@ void throwsException_whenBlank() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E04: 3자(최소 미만)이면, 예외가 발생한다.") + @DisplayName("3자(최소 미만)이면, 예외가 발생한다.") @Test void throwsException_whenLessThanMinLength() { // act & assert @@ -136,7 +136,7 @@ void throwsException_whenLessThanMinLength() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E05: 21자(최대 초과)이면, 예외가 발생한다.") + @DisplayName("21자(최대 초과)이면, 예외가 발생한다.") @Test void throwsException_whenExceedsMaxLength() { // arrange @@ -150,7 +150,7 @@ void throwsException_whenExceedsMaxLength() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E06: 특수문자가 포함되면, 예외가 발생한다.") + @DisplayName("특수문자가 포함되면, 예외가 발생한다.") @Test void throwsException_whenContainsSpecialCharacter() { // act & assert @@ -161,7 +161,7 @@ void throwsException_whenContainsSpecialCharacter() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E07: 한글이 포함되면, 예외가 발생한다.") + @DisplayName("한글이 포함되면, 예외가 발생한다.") @Test void throwsException_whenContainsKorean() { // act & assert @@ -172,7 +172,7 @@ void throwsException_whenContainsKorean() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E08: 숫자로 시작하면, 예외가 발생한다.") + @DisplayName("숫자로 시작하면, 예외가 발생한다.") @Test void throwsException_whenStartsWithNumber() { // act & assert @@ -183,7 +183,7 @@ void throwsException_whenStartsWithNumber() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("L-E09: 공백이 포함되면, 예외가 발생한다.") + @DisplayName("공백이 포함되면, 예외가 발생한다.") @Test void throwsException_whenContainsSpace() { // act & assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java new file mode 100644 index 00000000..db01dd51 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -0,0 +1,222 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * UserName Value Object 단위 테스트 + * + * 검증 규칙: + * - 한글, 영문만 허용 + * - 2~50자 + * - 공백 불허 + * + * 마스킹 규칙: + * - 마지막 1글자를 '*'로 대체 + */ +public class UserNameTest { + + @DisplayName("이름을 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @DisplayName("한글 이름이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenKorean() { + // arrange + String value = "홍길동"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @DisplayName("영문 이름이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenEnglish() { + // arrange + String value = "Nahyeon"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @DisplayName("최소 길이(2자)이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenMinLength() { + // arrange + String value = "홍길"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @DisplayName("최대 길이(50자)이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenMaxLength() { + // arrange + String value = "가".repeat(50); + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @DisplayName("한글+영문 혼합이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenKoreanAndEnglishMixed() { + // arrange + String value = "홍Nahyeon"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + // ========== 엣지 케이스 ========== + + @DisplayName("null이면, 예외가 발생한다.") + @Test + void throwsException_whenNull() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, 예외가 발생한다.") + @Test + void throwsException_whenEmpty() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(""); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("1자(최소 미만)이면, 예외가 발생한다.") + @Test + void throwsException_whenLessThanMinLength() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("51자(최대 초과)이면, 예외가 발생한다.") + @Test + void throwsException_whenExceedsMaxLength() { + // arrange + String value = "가".repeat(51); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("숫자가 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsDigits() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍길동123"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("특수문자가 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpecialChars() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍길동!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("공백이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsSpace() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍 길동"); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때,") + @Nested + class Masking { + + @DisplayName("3자 한글이면, 마지막 글자가 *로 대체된다.") + @Test + void masksLastChar_whenThreeCharKorean() { + // arrange + UserName userName = new UserName("홍길동"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("홍길*"); + } + + @DisplayName("2자 한글이면, 마지막 글자가 *로 대체된다.") + @Test + void masksLastChar_whenTwoCharKorean() { + // arrange + UserName userName = new UserName("홍길"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("홍*"); + } + + @DisplayName("영문이면, 마지막 글자가 *로 대체된다.") + @Test + void masksLastChar_whenEnglish() { + // arrange + UserName userName = new UserName("Nahyeon"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("Nahyeo*"); + } + } +} From 9f57cd0faeeead5103b2cc8b52833d64c9abfe46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 05:47:19 +0900 Subject: [PATCH 05/44] =?UTF-8?q?refactor:=20ErrorType=EC=9D=84=20interfac?= =?UTF-8?q?e=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EB=B3=84=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/example/ExampleModel.java | 8 ++--- .../domain/example/ExampleService.java | 4 +-- .../interfaces/api/ApiControllerAdvice.java | 15 +++++---- .../support/error/CommonErrorType.java | 19 +++++++++++ .../com/loopers/support/error/ErrorType.java | 20 +++--------- .../loopers/support/error/UserErrorType.java | 32 +++++++++++++++++++ .../domain/example/ExampleModelTest.java | 6 ++-- .../ExampleServiceIntegrationTest.java | 4 +-- .../support/error/CoreExceptionTest.java | 6 ++-- 9 files changed, 78 insertions(+), 36 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8..867cd70a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -1,8 +1,8 @@ package com.loopers.domain.example; import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -17,10 +17,10 @@ protected ExampleModel() {} public ExampleModel(String name, String description) { if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + throw new CoreException(CommonErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); } if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(CommonErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); } this.name = name; @@ -37,7 +37,7 @@ public String getDescription() { public void update(String newDescription) { if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(CommonErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); } this.description = newDescription; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java index c0e8431e..ba881f58 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -1,7 +1,7 @@ package com.loopers.domain.example; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +15,6 @@ public class ExampleService { @Transactional(readOnly = true) public ExampleModel getExample(Long id) { return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(CommonErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..911b6eb9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.extern.slf4j.Slf4j; @@ -35,7 +36,7 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown"; String value = e.getValue() != null ? e.getValue().toString() : "null"; String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } @ExceptionHandler @@ -43,7 +44,7 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara String name = e.getParameterName(); String type = e.getParameterType(); String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } @ExceptionHandler @@ -88,7 +89,7 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; } - return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + return failureResponse(CommonErrorType.BAD_REQUEST, errorMessage); } @ExceptionHandler @@ -96,21 +97,21 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); if (!missingParams.isEmpty()) { String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } else { - return failureResponse(ErrorType.BAD_REQUEST, null); + return failureResponse(CommonErrorType.BAD_REQUEST, null); } } @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { - return failureResponse(ErrorType.NOT_FOUND, null); + return failureResponse(CommonErrorType.NOT_FOUND, null); } @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); - return failureResponse(ErrorType.INTERNAL_ERROR, null); + return failureResponse(CommonErrorType.INTERNAL_ERROR, null); } private String extractMissingParameter(String message) { diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java new file mode 100644 index 00000000..3410657d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -0,0 +1,19 @@ +package com.loopers.support.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorType implements ErrorType { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file 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..435f7098 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 @@ -1,19 +1,9 @@ package com.loopers.support.error; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@Getter -@RequiredArgsConstructor -public enum ErrorType { - /** 범용 에러 */ - INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); - - private final HttpStatus status; - private final String code; - private final String message; -} +public interface ErrorType { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java new file mode 100644 index 00000000..c44962ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -0,0 +1,32 @@ +package com.loopers.support.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorType implements ErrorType { + // 400 Bad Request + INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "USER_001", "로그인 ID 형식이 올바르지 않습니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_002", "비밀번호 형식이 올바르지 않습니다."), + INVALID_NAME(HttpStatus.BAD_REQUEST, "USER_003", "이름 형식이 올바르지 않습니다."), + INVALID_BIRTH_DATE(HttpStatus.BAD_REQUEST, "USER_004", "생년월일 형식이 올바르지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "USER_005", "이메일 형식이 올바르지 않습니다."), + SAME_PASSWORD(HttpStatus.BAD_REQUEST, "USER_006", "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), + PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "USER_007", "비밀번호에 생년월일을 포함할 수 없습니다."), + + // 401 Unauthorized + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "USER_101", "인증에 실패했습니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "USER_102", "비밀번호가 일치하지 않습니다."), + + // 404 Not Found + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_201", "존재하지 않는 사용자입니다."), + + // 409 Conflict + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "USER_301", "이미 사용 중인 로그인 ID입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java index 44ca7576..2f283840 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.example; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -44,7 +44,7 @@ void throwsBadRequestException_whenTitleIsBlank() { }); // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getErrorType()).isEqualTo(CommonErrorType.BAD_REQUEST); } @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") @@ -59,7 +59,7 @@ void throwsBadRequestException_whenDescriptionIsEmpty() { }); // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getErrorType()).isEqualTo(CommonErrorType.BAD_REQUEST); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java index bbd5fdbe..78aefec7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -1,8 +1,8 @@ package com.loopers.domain.example; import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -66,7 +66,7 @@ void throwsException_whenInvalidIdIsProvided() { }); // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(exception.getErrorType()).isEqualTo(CommonErrorType.NOT_FOUND); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e..eed159d2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -10,10 +10,10 @@ class CoreExceptionTest { @Test void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { // arrange - ErrorType[] errorTypes = ErrorType.values(); + CommonErrorType[] errorTypes = CommonErrorType.values(); // act & assert - for (ErrorType errorType : errorTypes) { + for (CommonErrorType errorType : errorTypes) { CoreException exception = new CoreException(errorType); assertThat(exception.getMessage()).isEqualTo(errorType.getMessage()); } @@ -26,7 +26,7 @@ void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { String customMessage = "custom message"; // act - CoreException exception = new CoreException(ErrorType.INTERNAL_ERROR, customMessage); + CoreException exception = new CoreException(CommonErrorType.INTERNAL_ERROR, customMessage); // assert assertThat(exception.getMessage()).isEqualTo(customMessage); From f272db95112bd944d81d0da5edb12f8c6986bc5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 05:48:36 +0900 Subject: [PATCH 06/44] =?UTF-8?q?refactor:=20Password=20VO=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EA=B5=90=EC=B0=A8=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20PasswordPolicy=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/Password.java | 66 ++++------ .../loopers/domain/user/PasswordPolicy.java | 30 +++++ .../domain/user/PasswordPolicyTest.java | 65 ++++++++++ .../com/loopers/domain/user/PasswordTest.java | 115 ++++++------------ 4 files changed, 153 insertions(+), 123 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java index 49b8d706..b0ae5a89 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -1,27 +1,24 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import com.loopers.support.error.UserErrorType; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.regex.Pattern; /** - * 비밀번호 Value Object + * 비밀번호 Value Object (검증 전용) + * + * raw 비밀번호의 자체 규칙만 검증한다. + * - 암호화는 Service 레이어에서 담당 + * - 교차 검증(생년월일 포함 금지)은 PasswordPolicy Domain Service에서 담당 * * 검증 규칙: * - 8~16자 * - 영문 대소문자, 숫자, 특수문자만 허용 (공백, 한글 등 불가) * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 - * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) * - 동일 문자 3회 이상 연속 금지 (대소문자 구분 없음) * - 연속된 문자/숫자 3자리 이상 금지 (abc, 123 등) */ -@Embeddable public class Password { private static final int MIN_LENGTH = 8; @@ -29,53 +26,46 @@ public class Password { private static final int MIN_COMPLEXITY = 3; private static final int CONSECUTIVE_LIMIT = 3; private static final Pattern ALLOWED_CHARS = Pattern.compile("^[!-~]+$"); - private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); - - @Column(name = "password") - private String encodedValue; - protected Password() {} + private final String value; - private Password(String encodedValue) { - this.encodedValue = encodedValue; + private Password(String value) { + this.value = value; } - public static Password of(String rawPassword, LocalDate birthDate) { - validate(rawPassword, birthDate); - return new Password(ENCODER.encode(rawPassword)); + public static Password of(String rawPassword) { + validate(rawPassword); + return new Password(rawPassword); } - public boolean matches(String rawPassword) { - return ENCODER.matches(rawPassword, this.encodedValue); + public String getValue() { + return value; } - private static void validate(String rawPassword, LocalDate birthDate) { + private static void validate(String rawPassword) { validateNotBlank(rawPassword); validateLength(rawPassword); validateAllowedChars(rawPassword); validateComplexity(rawPassword); - validateBirthDateNotContained(rawPassword, birthDate); validateNoConsecutiveSameChars(rawPassword); validateNoSequentialChars(rawPassword); } private static void validateNotBlank(String rawPassword) { if (rawPassword == null || rawPassword.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } private static void validateLength(String rawPassword) { if (rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호는 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } private static void validateAllowedChars(String rawPassword) { if (!ALLOWED_CHARS.matcher(rawPassword).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용할 수 있습니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } @@ -87,19 +77,7 @@ private static void validateComplexity(String rawPassword) { if (rawPassword.chars().anyMatch(c -> !Character.isLetterOrDigit(c))) typeCount++; if (typeCount < MIN_COMPLEXITY) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호는 영문 대문자, 소문자, 숫자, 특수문자 중 " + MIN_COMPLEXITY + "종류 이상 포함해야 합니다."); - } - } - - private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { - String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); - String yymmdd = yyyymmdd.substring(2); - String mmdd = yyyymmdd.substring(4); - - if (rawPassword.contains(yyyymmdd) || rawPassword.contains(yymmdd) || rawPassword.contains(mmdd)) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호에 생년월일을 포함할 수 없습니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } @@ -115,8 +93,7 @@ private static void validateNoConsecutiveSameChars(String rawPassword) { } } if (allSame) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호에 동일 문자를 " + CONSECUTIVE_LIMIT + "회 이상 연속 사용할 수 없습니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } } @@ -135,8 +112,7 @@ private static void validateNoSequentialChars(String rawPassword) { boolean ascending = (c2 - c1 == 1) && (c3 - c2 == 1); boolean descending = (c1 - c2 == 1) && (c2 - c3 == 1); if (ascending || descending) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호에 연속된 문자 또는 숫자를 " + CONSECUTIVE_LIMIT + "자리 이상 사용할 수 없습니다."); + throw new CoreException(UserErrorType.INVALID_PASSWORD); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java new file mode 100644 index 00000000..986ec36c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java @@ -0,0 +1,30 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 비밀번호 교차 검증 정책 (Domain Service) + * + * Password VO 자체로는 판단할 수 없는, 다른 도메인 값과의 관계를 검증한다. + * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) + */ +public class PasswordPolicy { + + public static void validate(String rawPassword, LocalDate birthDate) { + validateBirthDateNotContained(rawPassword, birthDate); + } + + private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String yymmdd = yyyymmdd.substring(2); + String mmdd = yyyymmdd.substring(4); + + if (rawPassword.contains(yyyymmdd) || rawPassword.contains(yymmdd) || rawPassword.contains(mmdd)) { + throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java new file mode 100644 index 00000000..e0ea7b13 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java @@ -0,0 +1,65 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +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.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * PasswordPolicy 교차 검증 단위 테스트 + * + * 검증 규칙: + * - 비밀번호에 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) + */ +public class PasswordPolicyTest { + + private static final LocalDate BIRTH_DATE = LocalDate.of(1994, 11, 15); + + @DisplayName("비밀번호 교차 검증 시,") + @Nested + class Validate { + + @DisplayName("생년월일이 포함되지 않으면, 정상 통과한다.") + @Test + void passes_whenNoBirthDate() { + assertDoesNotThrow(() -> PasswordPolicy.validate("Hx7!mK2@", BIRTH_DATE)); + } + + @DisplayName("생년월일(YYYYMMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateYYYYMMDD() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate("A19941115!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일(YYMMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateYYMMDD() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate("A941115!a", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + + @DisplayName("생년월일(MMDD)이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenContainsBirthDateMMDD() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate("Abcd1115!", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index d8880555..50908fac 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -1,14 +1,13 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; 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.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -18,14 +17,13 @@ * - 8~16자 * - 영문 대소문자, 숫자, 특수문자만 허용 * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 - * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) * - 동일 문자 3회 이상 연속 금지 * - 연속된 문자/숫자 3자리 이상 금지 + * + * 교차 검증(생년월일 포함 금지)은 PasswordPolicy에서 별도 테스트 */ public class PasswordTest { - private static final LocalDate BIRTH_DATE = LocalDate.of(1994, 11, 15); - @DisplayName("비밀번호를 생성할 때,") @Nested class Create { @@ -39,10 +37,10 @@ void createsPassword_whenAllRulesSatisfied() { String rawPassword = "Hx7!mK2@"; // act - Password password = Password.of(rawPassword, BIRTH_DATE); + Password password = Password.of(rawPassword); // assert - assertThat(password.matches(rawPassword)).isTrue(); + assertThat(password.getValue()).isEqualTo(rawPassword); } @DisplayName("최소 길이(8자)이면, 정상적으로 생성된다.") @@ -52,10 +50,10 @@ void createsPassword_whenMinLength() { String rawPassword = "Xz5!qw9@"; // 8자 // act - Password password = Password.of(rawPassword, BIRTH_DATE); + Password password = Password.of(rawPassword); // assert - assertThat(password.matches(rawPassword)).isTrue(); + assertThat(password.getValue()).isEqualTo(rawPassword); } @DisplayName("최대 길이(16자)이면, 정상적으로 생성된다.") @@ -65,10 +63,10 @@ void createsPassword_whenMaxLength() { String rawPassword = "Px8!Kd3@Wm7#Rf2$"; // 16자 // act - Password password = Password.of(rawPassword, BIRTH_DATE); + Password password = Password.of(rawPassword); // assert - assertThat(password.matches(rawPassword)).isTrue(); + assertThat(password.getValue()).isEqualTo(rawPassword); } @DisplayName("다양한 특수문자 조합이면, 정상적으로 생성된다.") @@ -77,11 +75,8 @@ void createsPassword_whenVariousSpecialChars() { // arrange String rawPassword = "Ac1~`[]{}"; - // act - Password password = Password.of(rawPassword, BIRTH_DATE); - - // assert - assertThat(password.matches(rawPassword)).isTrue(); + // act & assert + assertDoesNotThrow(() -> Password.of(rawPassword)); } // ========== 엣지 케이스 ========== @@ -91,10 +86,10 @@ void createsPassword_whenVariousSpecialChars() { void throwsException_whenNull() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of(null, BIRTH_DATE); + Password.of(null); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("빈 문자열이면, 예외가 발생한다.") @@ -102,10 +97,10 @@ void throwsException_whenNull() { void throwsException_whenEmpty() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("", BIRTH_DATE); + Password.of(""); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("7자(최소 미만)이면, 예외가 발생한다.") @@ -113,10 +108,10 @@ void throwsException_whenEmpty() { void throwsException_whenLessThanMinLength() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd12!", BIRTH_DATE); // 7자 + Password.of("Abcd12!"); // 7자 }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("17자(최대 초과)이면, 예외가 발생한다.") @@ -127,10 +122,10 @@ void throwsException_whenExceedsMaxLength() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of(rawPassword, BIRTH_DATE); + Password.of(rawPassword); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("영문만 있으면(복잡도 미달), 예외가 발생한다.") @@ -138,10 +133,10 @@ void throwsException_whenExceedsMaxLength() { void throwsException_whenOnlyLetters() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcdefgh", BIRTH_DATE); // 대문자+소문자 = 2종 + Password.of("Abcdefgh"); // 대문자+소문자 = 2종 }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("숫자만 있으면(복잡도 미달), 예외가 발생한다.") @@ -149,10 +144,10 @@ void throwsException_whenOnlyLetters() { void throwsException_whenOnlyDigits() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("12345978", BIRTH_DATE); // 숫자 = 1종 + Password.of("12345978"); // 숫자 = 1종 }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("특수문자만 있으면(복잡도 미달), 예외가 발생한다.") @@ -160,10 +155,10 @@ void throwsException_whenOnlyDigits() { void throwsException_whenOnlySpecialChars() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("!@#$%^&*", BIRTH_DATE); // 특수문자 = 1종 + Password.of("!@#$%^&*"); // 특수문자 = 1종 }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("한글이 포함되면, 예외가 발생한다.") @@ -171,10 +166,10 @@ void throwsException_whenOnlySpecialChars() { void throwsException_whenContainsKorean() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd123가", BIRTH_DATE); + Password.of("Abcd123가"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("공백이 포함되면, 예외가 발생한다.") @@ -182,46 +177,10 @@ void throwsException_whenContainsKorean() { void throwsException_whenContainsSpace() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd 12!", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("생년월일(YYYYMMDD)이 포함되면, 예외가 발생한다.") - @Test - void throwsException_whenContainsBirthDateYYYYMMDD() { - // arrange - birthDate: 1994-11-15 - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("A19941115!", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("생년월일(MMDD)이 포함되면, 예외가 발생한다.") - @Test - void throwsException_whenContainsBirthDateMMDD() { - // arrange - birthDate: 1994-11-15 - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd0115!", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("생년월일(YYMMDD)이 포함되면, 예외가 발생한다.") - @Test - void throwsException_whenContainsBirthDateYYMMDD() { - // arrange - birthDate: 1994-11-15 - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("A941115!a", BIRTH_DATE); + Password.of("Abcd 12!"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("동일 문자가 3회 이상 연속되면, 예외가 발생한다.") @@ -229,10 +188,10 @@ void throwsException_whenContainsBirthDateYYMMDD() { void throwsException_whenThreeConsecutiveSameChars() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Aaab123!@", BIRTH_DATE); + Password.of("Aaab123!@"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("연속 숫자 3자리가 포함되면, 예외가 발생한다.") @@ -240,10 +199,10 @@ void throwsException_whenThreeConsecutiveSameChars() { void throwsException_whenThreeSequentialDigits() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abzx1234!", BIRTH_DATE); + Password.of("Abzx1234!"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @DisplayName("연속 문자 3자리가 포함되면, 예외가 발생한다.") @@ -251,10 +210,10 @@ void throwsException_whenThreeSequentialDigits() { void throwsException_whenThreeSequentialChars() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("xAbcz12!@", BIRTH_DATE); + Password.of("xAbcz12!@"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } } -} +} \ No newline at end of file From 7fa465be5aea9dd8620edecc7dc7adf806b632b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 05:49:34 +0900 Subject: [PATCH 07/44] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20VO=EC=97=90=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=97=90=EB=9F=AC=ED=83=80=EC=9E=85(UserE?= =?UTF-8?q?rrorType)=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/BirthDate.java | 12 +++++------ .../java/com/loopers/domain/user/Email.java | 10 +++++----- .../java/com/loopers/domain/user/LoginId.java | 8 ++++---- .../com/loopers/domain/user/UserName.java | 8 ++++---- .../loopers/domain/user/BirthDateTest.java | 18 ++++++++--------- .../com/loopers/domain/user/EmailTest.java | 20 +++++++++---------- .../com/loopers/domain/user/LoginIdTest.java | 20 +++++++++---------- .../com/loopers/domain/user/UserNameTest.java | 16 +++++++-------- 8 files changed, 56 insertions(+), 56 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java index 564e287d..d33a9b4e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -41,7 +41,7 @@ public LocalDate getValue() { private static void validateNotBlank(String rawValue) { if (rawValue == null || rawValue.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); } } @@ -49,24 +49,24 @@ private static LocalDate parseDate(String rawValue) { try { return LocalDate.parse(rawValue); } catch (DateTimeParseException e) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 YYYY-MM-DD 형식이어야 합니다."); } } private static void validateRange(LocalDate date) { if (date.isBefore(MIN_DATE)) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 " + MIN_DATE + " 이후여야 합니다."); } if (date.isAfter(LocalDate.now())) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 미래 날짜일 수 없습니다."); } if (Period.between(date, LocalDate.now()).getYears() < MIN_AGE) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "만 " + MIN_AGE + "세 이상만 가입할 수 있습니다."); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java index 94879b0d..35a89322 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -41,16 +41,16 @@ public String getValue() { private void validate(String value) { if (value == null || value.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일은 필수입니다."); } if (value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일은 최대 " + MAX_LENGTH + "자까지 가능합니다."); } if (!EMAIL_PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일 형식이 올바르지 않습니다."); } @@ -60,7 +60,7 @@ private void validate(String value) { private void validateNoConsecutiveDots(String value) { String localPart = value.substring(0, value.indexOf('@')); if (localPart.contains("..")) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일 로컬 파트에 연속된 점(.)을 사용할 수 없습니다."); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java index 6f682212..f41fe01c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -35,16 +35,16 @@ public LoginId(String value) { private void validate(String value) { if (value == null || value.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, "로그인 ID는 필수입니다."); } if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, "로그인 ID는 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); } if (!PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, "로그인 ID는 영문으로 시작하고, 영문과 숫자만 사용할 수 있습니다."); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java index ab5c3926..3596972a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -45,16 +45,16 @@ public String getMaskedValue() { private void validate(String value) { if (value == null || value.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + throw new CoreException(UserErrorType.INVALID_NAME, "이름은 필수입니다."); } if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_NAME, "이름은 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); } if (!PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, + throw new CoreException(UserErrorType.INVALID_NAME, "이름은 한글과 영문만 사용할 수 있습니다."); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index 06837b47..cfe35111 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -91,7 +91,7 @@ void throwsException_whenNull() { new BirthDate(null); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("잘못된 형식(슬래시)이면, 예외가 발생한다.") @@ -102,7 +102,7 @@ void throwsException_whenSlashFormat() { new BirthDate("1994/11/15"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("잘못된 형식(구분자 없음)이면, 예외가 발생한다.") @@ -113,7 +113,7 @@ void throwsException_whenNoSeparator() { new BirthDate("19941115"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("존재하지 않는 날짜(2월 30일)이면, 예외가 발생한다.") @@ -124,7 +124,7 @@ void throwsException_whenInvalidDate() { new BirthDate("1994-02-30"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("미래 날짜이면, 예외가 발생한다.") @@ -135,7 +135,7 @@ void throwsException_whenFutureDate() { new BirthDate("2030-01-01"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("1900년 이전이면, 예외가 발생한다.") @@ -146,7 +146,7 @@ void throwsException_whenTooOld() { new BirthDate("1899-12-31"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("만 14세 미만이면, 예외가 발생한다.") @@ -160,7 +160,7 @@ void throwsException_whenUnderMinAge() { new BirthDate(value); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } @DisplayName("비윤년 2월 29일이면, 예외가 발생한다.") @@ -171,7 +171,7 @@ void throwsException_whenNonLeapYearFeb29() { new BirthDate("1999-02-29"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 07bf53e9..c1224fa3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -76,7 +76,7 @@ void throwsException_whenNull() { new Email(null); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("빈 문자열이면, 예외가 발생한다.") @@ -87,7 +87,7 @@ void throwsException_whenEmpty() { new Email(""); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("@가 없으면, 예외가 발생한다.") @@ -98,7 +98,7 @@ void throwsException_whenNoAtSign() { new Email("nahyeonexample.com"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("도메인이 없으면, 예외가 발생한다.") @@ -109,7 +109,7 @@ void throwsException_whenNoDomain() { new Email("nahyeon@"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("로컬 파트가 없으면, 예외가 발생한다.") @@ -120,7 +120,7 @@ void throwsException_whenNoLocalPart() { new Email("@example.com"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("연속 점이 포함되면, 예외가 발생한다.") @@ -131,7 +131,7 @@ void throwsException_whenConsecutiveDots() { new Email("nahyeon..lim@example.com"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("공백이 포함되면, 예외가 발생한다.") @@ -142,7 +142,7 @@ void throwsException_whenContainsSpace() { new Email("nahyeon lim@example.com"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("256자를 초과하면, 예외가 발생한다.") @@ -156,7 +156,7 @@ void throwsException_whenExceedsMaxLength() { new Email(value); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @DisplayName("한글이 포함되면, 예외가 발생한다.") @@ -167,7 +167,7 @@ void throwsException_whenContainsKorean() { new Email("홍길동@example.com"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index 31ee06a7..75ad425b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -100,7 +100,7 @@ void throwsException_whenNull() { new LoginId(null); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("빈 문자열이면, 예외가 발생한다.") @@ -111,7 +111,7 @@ void throwsException_whenEmpty() { new LoginId(""); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("공백만 있으면, 예외가 발생한다.") @@ -122,7 +122,7 @@ void throwsException_whenBlank() { new LoginId(" "); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("3자(최소 미만)이면, 예외가 발생한다.") @@ -133,7 +133,7 @@ void throwsException_whenLessThanMinLength() { new LoginId("abc"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("21자(최대 초과)이면, 예외가 발생한다.") @@ -147,7 +147,7 @@ void throwsException_whenExceedsMaxLength() { new LoginId(value); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("특수문자가 포함되면, 예외가 발생한다.") @@ -158,7 +158,7 @@ void throwsException_whenContainsSpecialCharacter() { new LoginId("nahyeon@123"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("한글이 포함되면, 예외가 발생한다.") @@ -169,7 +169,7 @@ void throwsException_whenContainsKorean() { new LoginId("nahyeon홍"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("숫자로 시작하면, 예외가 발생한다.") @@ -180,7 +180,7 @@ void throwsException_whenStartsWithNumber() { new LoginId("123nahyeon"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } @DisplayName("공백이 포함되면, 예외가 발생한다.") @@ -191,7 +191,7 @@ void throwsException_whenContainsSpace() { new LoginId("nahyeon Lim"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java index db01dd51..1a07a8dc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -103,7 +103,7 @@ void throwsException_whenNull() { new UserName(null); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("빈 문자열이면, 예외가 발생한다.") @@ -114,7 +114,7 @@ void throwsException_whenEmpty() { new UserName(""); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("1자(최소 미만)이면, 예외가 발생한다.") @@ -125,7 +125,7 @@ void throwsException_whenLessThanMinLength() { new UserName("홍"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("51자(최대 초과)이면, 예외가 발생한다.") @@ -139,7 +139,7 @@ void throwsException_whenExceedsMaxLength() { new UserName(value); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("숫자가 포함되면, 예외가 발생한다.") @@ -150,7 +150,7 @@ void throwsException_whenContainsDigits() { new UserName("홍길동123"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("특수문자가 포함되면, 예외가 발생한다.") @@ -161,7 +161,7 @@ void throwsException_whenContainsSpecialChars() { new UserName("홍길동!"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } @DisplayName("공백이 포함되면, 예외가 발생한다.") @@ -172,7 +172,7 @@ void throwsException_whenContainsSpace() { new UserName("홍 길동"); }); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } } From 3013f157aff623e9c5c24260e6817b01d82538b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 05:51:11 +0900 Subject: [PATCH 08/44] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85,=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=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/application/user/UserFacade.java | 27 +++++++ .../loopers/application/user/UserInfo.java | 23 ++++++ .../java/com/loopers/domain/user/User.java | 49 ++++++++++++ .../loopers/domain/user/UserRepository.java | 9 +++ .../com/loopers/domain/user/UserService.java | 76 +++++++++++++++++++ .../user/UserJpaRepository.java | 11 +++ .../user/UserRepositoryImpl.java | 29 +++++++ .../interfaces/api/user/UserV1ApiSpec.java | 18 +++++ .../interfaces/api/user/UserV1Controller.java | 47 ++++++++++++ .../interfaces/api/user/UserV1Dto.java | 36 +++++++++ .../support/config/SecurityConfig.java | 15 ++++ 11 files changed, 340 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..8fd34827 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo signup(String loginId, String password, String name, String birthDate, String email) { + User user = userService.signup(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId, String password) { + User user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String headerPassword, String currentPassword, String newPassword) { + User user = userService.authenticate(loginId, headerPassword); + userService.changePassword(user, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..393a0ef7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo( + String loginId, + String name, + String maskedName, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getLoginId().getValue(), + user.getName().getValue(), + user.getName().getMaskedValue(), + user.getBirthDate(), + user.getEmail().getValue() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..2e7ab6f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,49 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; + +@Entity +@Table(name = "users") +@Getter +public class User extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Embedded + private UserName name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Embedded + private Email email; + + protected User() {} + + private User(LoginId loginId, String encodedPassword, UserName name, LocalDate birthDate, Email email) { + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static User create(LoginId loginId, String encodedPassword, UserName name, LocalDate birthDate, Email email) { + return new User(loginId, encodedPassword, name, birthDate, email); + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..15889936 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..7ab35147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,76 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public User signup(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { + // 1. VO 생성 (각 VO가 자체 규칙 검증) + LoginId loginId = new LoginId(rawLoginId); + Password password = Password.of(rawPassword); + UserName name = new UserName(rawName); + BirthDate birthDate = new BirthDate(rawBirthDate); + Email email = new Email(rawEmail); + + // 2. 교차 검증 (비밀번호에 생년월일 포함 불가) + PasswordPolicy.validate(rawPassword, birthDate.getValue()); + + // 3. 중복 ID 검증 + if (userRepository.existsByLoginId(loginId.getValue())) { + throw new CoreException(UserErrorType.DUPLICATE_LOGIN_ID); + } + + // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 + String encodedPassword = passwordEncoder.encode(rawPassword); + User user = User.create(loginId, encodedPassword, name, birthDate.getValue(), email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User authenticate(String rawLoginId, String rawPassword) { + User user = userRepository.findByLoginId(rawLoginId) + .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.UNAUTHORIZED); + } + + return user; + } + + @Transactional + public void changePassword(User user, String currentRawPassword, String newRawPassword) { + // 현재 비밀번호 확인 + if (!passwordEncoder.matches(currentRawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.PASSWORD_MISMATCH); + } + + // 새 비밀번호 규칙 검증 + Password newPassword = Password.of(newRawPassword); + + // 교차 검증 + PasswordPolicy.validate(newRawPassword, user.getBirthDate()); + + // 동일 비밀번호 확인 + if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.SAME_PASSWORD); + } + + // 암호화 후 변경 + user.changePassword(passwordEncoder.encode(newRawPassword)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..f7745368 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..4768ffe8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginIdValue(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginIdValue(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..8ddbaa5e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,18 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "회원 관련 API") +public interface UserV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse signup(UserV1Dto.SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") + ApiResponse getMyInfo(String loginId, String loginPw); + + @Operation(summary = "비밀번호 수정", description = "인증된 사용자의 비밀번호를 변경합니다.") + ApiResponse changePassword(String loginId, String loginPw, UserV1Dto.ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..04584ffa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signup(@RequestBody UserV1Dto.SignupRequest request) { + UserInfo info = userFacade.signup( + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + ); + return ApiResponse.success(UserV1Dto.UserResponse.fromSignup(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + UserInfo info = userFacade.getMyInfo(loginId, loginPw); + return ApiResponse.success(UserV1Dto.UserResponse.fromMe(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..abf90cb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record UserResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserResponse fromSignup(UserInfo info) { + return new UserResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + + public static UserResponse fromMe(UserInfo info) { + return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); + } + } +} 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..c019a8cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} From f3f313020cb36291084ac2f4fe013625612c9365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 05:51:48 +0900 Subject: [PATCH 09/44] =?UTF-8?q?test:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20API=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- .../loopers/domain/user/UserServiceTest.java | 213 +++++++++++++++++ .../com/loopers/domain/user/UserTest.java | 69 ++++++ .../interfaces/api/UserV1ApiE2ETest.java | 220 ++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 00000000..6b3514fc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,213 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * UserService 단위 테스트 + */ +public class UserServiceTest { + + private UserRepository userRepository; + private PasswordEncoder passwordEncoder; + private UserService userService; + + @BeforeEach + void setUp() { + userRepository = Mockito.mock(UserRepository.class); + passwordEncoder = Mockito.mock(PasswordEncoder.class); + userService = new UserService(userRepository, passwordEncoder); + } + + @DisplayName("회원가입 시,") + @Nested + class Signup { + + @DisplayName("유효한 정보면, 정상적으로 가입된다.") + @Test + void signup_whenValidInput() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$encodedHash"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + User user = userService.signup("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert + assertAll( + () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(user.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(user.getBirthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + } + + @DisplayName("이미 존재하는 로그인 ID면, 예외가 발생한다.") + @Test + void throwsException_whenDuplicateLoginId() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(true); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.signup("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + } + + @DisplayName("비밀번호에 생년월일이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenPasswordContainsBirthDate() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "19900325" + CoreException exception = assertThrows(CoreException.class, () -> { + userService.signup("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + @DisplayName("인증 시,") + @Nested + class Authenticate { + + @DisplayName("유효한 ID/PW면, 사용자를 반환한다.") + @Test + void returnsUser_whenValidCredentials() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + + // act + User result = userService.authenticate("nahyeon", "Hx7!mK2@"); + + // assert + assertThat(result.getLoginId().getValue()).isEqualTo("nahyeon"); + } + + @DisplayName("존재하지 않는 ID면, 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticate("unknown", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 불일치하면, 예외가 발생한다.") + @Test + void throwsException_whenPasswordMismatch() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticate("nahyeon", "wrongPw1!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenValidRequest() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$oldHash", + new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); + when(passwordEncoder.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); + when(passwordEncoder.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); + + // act + userService.changePassword(user, "Hx7!mK2@", "Nw8@pL3#"); + + // assert + assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); + } + + @DisplayName("현재 비밀번호가 틀리면, 예외가 발생한다.") + @Test + void throwsException_whenCurrentPasswordWrong() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword(user, "wrongPw!", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + } + + @DisplayName("새 비밀번호가 현재와 동일하면, 예외가 발생한다.") + @Test + void throwsException_whenSameAsCurrentPassword() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // current matches + when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // same check also matches + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword(user, "Hx7!mK2@", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..b0148bab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.user; + +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; + +/** + * User Entity 단위 테스트 + */ +public class UserTest { + + @DisplayName("User를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보를 전달하면, 정상적으로 생성된다.") + @Test + void createsUser_whenValidInput() { + // arrange + LoginId loginId = new LoginId("nahyeon"); + String encodedPassword = "$2a$10$encodedPasswordHash"; + UserName name = new UserName("홍길동"); + LocalDate birthDate = LocalDate.of(1994, 11, 15); + Email email = new Email("nahyeon@example.com"); + + // act + User user = User.create(loginId, encodedPassword, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(user.getPassword()).isEqualTo(encodedPassword), + () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("새 인코딩된 비밀번호로 변경된다.") + @Test + void changesPassword_whenNewEncodedPasswordGiven() { + // arrange + User user = User.create( + new LoginId("nahyeon"), + "$2a$10$oldHash", + new UserName("홍길동"), + LocalDate.of(1994, 11, 15), + new Email("nahyeon@example.com") + ); + String newEncodedPassword = "$2a$10$newHash"; + + // act + user.changePassword(newEncodedPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newEncodedPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..5eef27f3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,220 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String SIGNUP_URL = "/api/v1/users/signup"; + private static final String ME_URL = "/api/v1/users/me"; + private static final String CHANGE_PW_URL = "/api/v1/users/me/password"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private UserV1Dto.SignupRequest validSignupRequest() { + return new UserV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + } + + private ResponseEntity signup(UserV1Dto.SignupRequest request) { + return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + // ========== 회원가입 ========== + + @DisplayName("POST /api/v1/users/signup") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면, 201 Created 응답을 받는다.") + @Test + void returns201_whenValidRequest() { + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @DisplayName("중복 로그인 ID로 가입하면, 409 Conflict 응답을 받는다.") + @Test + void returns409_whenDuplicateLoginId() { + // arrange + signup(validSignupRequest()); + + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("잘못된 비밀번호 형식이면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenInvalidPassword() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일이 포함되면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenPasswordContainsBirthDate() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // ========== 내 정보 조회 ========== + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMyInfo { + + @DisplayName("유효한 인증 정보로 조회하면, 200 OK + 마스킹된 이름을 반환한다.") + @Test + void returns200WithMaskedName_whenValidAuth() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + + // act + ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), type); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("nahyeon") + ); + } + + @DisplayName("잘못된 비밀번호로 조회하면, 401 Unauthorized 응답을 받는다.") + @Test + void returns401_whenWrongPassword() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!"); + + // act + ResponseEntity response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // ========== 비밀번호 수정 ========== + + @DisplayName("PATCH /api/v1/users/me/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면, 200 OK 응답을 받는다.") + @Test + void returns200_whenValidRequest() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + + // act + ResponseEntity response = testRestTemplate.exchange( + CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("변경 후 새 비밀번호로 인증되고, 이전 비밀번호로는 실패한다.") + @Test + void authenticatesWithNewPassword_afterChange() { + // arrange - 회원가입 + 비밀번호 변경 + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // act - 새 비밀번호로 조회 + HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); + ResponseEntity newPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(newHeaders), ApiResponse.class); + + // act - 이전 비밀번호로 조회 + HttpHeaders oldHeaders = authHeaders("nahyeon", "Hx7!mK2@"); + ResponseEntity oldPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(oldHeaders), ApiResponse.class); + + // assert + assertAll( + () -> assertThat(newPwResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(oldPwResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("현재 비밀번호와 동일한 새 비밀번호면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenSamePassword() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); + + // act + ResponseEntity response = testRestTemplate.exchange( + CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} From 8829cdf3b836feb83072288f374eab9750d00798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 16:20:38 +0900 Subject: [PATCH 10/44] =?UTF-8?q?refactor:=20Auth=20User=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Auth=20Te?= =?UTF-8?q?st=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/auth/AuthFacade.java | 23 +++ .../loopers/application/user/UserFacade.java | 10 - .../interfaces/api/auth/AuthV1ApiSpec.java | 15 ++ .../interfaces/api/auth/AuthV1Controller.java | 37 ++++ .../interfaces/api/auth/AuthV1Dto.java | 32 ++++ .../interfaces/api/user/UserV1ApiSpec.java | 8 +- .../interfaces/api/user/UserV1Controller.java | 24 +-- .../interfaces/api/user/UserV1Dto.java | 19 +- .../interfaces/api/AuthV1ApiE2ETest.java | 177 ++++++++++++++++++ .../interfaces/api/UserV1ApiE2ETest.java | 138 +------------- 10 files changed, 292 insertions(+), 191 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java new file mode 100644 index 00000000..4560cb1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -0,0 +1,23 @@ +package com.loopers.application.auth; + +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class AuthFacade { + private final UserService userService; + + public UserInfo signup(String loginId, String password, String name, String birthDate, String email) { + User user = userService.signup(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String headerPassword, String currentPassword, String newPassword) { + User user = userService.authenticate(loginId, headerPassword); + userService.changePassword(user, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 8fd34827..f5a49282 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -10,18 +10,8 @@ public class UserFacade { private final UserService userService; - public UserInfo signup(String loginId, String password, String name, String birthDate, String email) { - User user = userService.signup(loginId, password, name, birthDate, email); - return UserInfo.from(user); - } - public UserInfo getMyInfo(String loginId, String password) { User user = userService.authenticate(loginId, password); return UserInfo.from(user); } - - public void changePassword(String loginId, String headerPassword, String currentPassword, String newPassword) { - User user = userService.authenticate(loginId, headerPassword); - userService.changePassword(user, currentPassword, newPassword); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java new file mode 100644 index 00000000..9ad7f5de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Auth V1 API", description = "인증 관련 API") +public interface AuthV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse signup(AuthV1Dto.SignupRequest request); + + @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") + ApiResponse changePassword(String loginId, String loginPw, AuthV1Dto.ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java new file mode 100644 index 00000000..bdc66a73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.auth.AuthFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/auth") +public class AuthV1Controller implements AuthV1ApiSpec { + + private final AuthFacade authFacade; + + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signup(@RequestBody AuthV1Dto.SignupRequest request) { + UserInfo info = authFacade.signup( + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + ); + return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); + } + + @PatchMapping("/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestBody AuthV1Dto.ChangePasswordRequest request + ) { + authFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java new file mode 100644 index 00000000..75c6310f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +public class AuthV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record SignupResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static SignupResponse from(UserInfo info) { + return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 8ddbaa5e..2fe92fa2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -4,15 +4,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "User V1 API", description = "회원 관련 API") +@Tag(name = "User V1 API", description = "사용자 정보 관련 API") public interface UserV1ApiSpec { - @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") - ApiResponse signup(UserV1Dto.SignupRequest request); - @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") ApiResponse getMyInfo(String loginId, String loginPw); - - @Operation(summary = "비밀번호 수정", description = "인증된 사용자의 비밀번호를 변경합니다.") - ApiResponse changePassword(String loginId, String loginPw, UserV1Dto.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 04584ffa..4419c795 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -4,7 +4,6 @@ import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @@ -14,16 +13,6 @@ public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; - @PostMapping("/signup") - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse signup(@RequestBody UserV1Dto.SignupRequest request) { - UserInfo info = userFacade.signup( - request.loginId(), request.password(), request.name(), request.birthDate(), request.email() - ); - return ApiResponse.success(UserV1Dto.UserResponse.fromSignup(info)); - } - @GetMapping("/me") @Override public ApiResponse getMyInfo( @@ -31,17 +20,6 @@ public ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginPw") String loginPw ) { UserInfo info = userFacade.getMyInfo(loginId, loginPw); - return ApiResponse.success(UserV1Dto.UserResponse.fromMe(info)); - } - - @PatchMapping("/me/password") - @Override - public ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @RequestBody UserV1Dto.ChangePasswordRequest request - ) { - userFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); - return ApiResponse.success(null); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index abf90cb4..9af0b68a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -6,30 +6,13 @@ public class UserV1Dto { - public record SignupRequest( - String loginId, - String password, - String name, - String birthDate, - String email - ) {} - - public record ChangePasswordRequest( - String currentPassword, - String newPassword - ) {} - public record UserResponse( String loginId, String name, LocalDate birthDate, String email ) { - public static UserResponse fromSignup(UserInfo info) { - return new UserResponse(info.loginId(), info.name(), info.birthDate(), info.email()); - } - - public static UserResponse fromMe(UserInfo info) { + public static UserResponse from(UserInfo info) { return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java new file mode 100644 index 00000000..866ca298 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -0,0 +1,177 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.auth.AuthV1Dto; +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.http.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AuthV1ApiE2ETest { + + private static final String SIGNUP_URL = "/api/v1/auth/signup"; + private static final String CHANGE_PW_URL = "/api/v1/auth/password"; + private static final String ME_URL = "/api/v1/users/me"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private AuthV1Dto.SignupRequest validSignupRequest() { + return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + } + + private ResponseEntity signup(AuthV1Dto.SignupRequest request) { + return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + // ========== 회원가입 ========== + + @DisplayName("POST /api/v1/auth/signup") + @Nested + class Signup { + + @DisplayName("유효한 정보로 가입하면, 201 Created 응답을 받는다.") + @Test + void returns201_whenValidRequest() { + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @DisplayName("중복 로그인 ID로 가입하면, 409 Conflict 응답을 받는다.") + @Test + void returns409_whenDuplicateLoginId() { + // arrange + signup(validSignupRequest()); + + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("잘못된 비밀번호 형식이면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenInvalidPassword() { + // arrange + AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( + "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일이 포함되면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenPasswordContainsBirthDate() { + // arrange + AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( + "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // ========== 비밀번호 변경 ========== + + @DisplayName("PATCH /api/v1/auth/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면, 200 OK 응답을 받는다.") + @Test + void returns200_whenValidRequest() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + + // act + ResponseEntity response = testRestTemplate.exchange( + CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("변경 후 새 비밀번호로 인증되고, 이전 비밀번호로는 실패한다.") + @Test + void authenticatesWithNewPassword_afterChange() { + // arrange - 회원가입 + 비밀번호 변경 + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // act - 새 비밀번호로 조회 + HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); + ResponseEntity newPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(newHeaders), ApiResponse.class); + + // act - 이전 비밀번호로 조회 + HttpHeaders oldHeaders = authHeaders("nahyeon", "Hx7!mK2@"); + ResponseEntity oldPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(oldHeaders), ApiResponse.class); + + // assert + assertAll( + () -> assertThat(newPwResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(oldPwResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("현재 비밀번호와 동일한 새 비밀번호면, 400 Bad Request 응답을 받는다.") + @Test + void returns400_whenSamePassword() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); + + // act + ResponseEntity response = testRestTemplate.exchange( + CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 5eef27f3..a453572c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api; +import com.loopers.interfaces.api.auth.AuthV1Dto; import com.loopers.interfaces.api.user.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -18,9 +19,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class UserV1ApiE2ETest { - private static final String SIGNUP_URL = "/api/v1/users/signup"; + private static final String SIGNUP_URL = "/api/v1/auth/signup"; private static final String ME_URL = "/api/v1/users/me"; - private static final String CHANGE_PW_URL = "/api/v1/users/me/password"; @Autowired private TestRestTemplate testRestTemplate; @@ -33,11 +33,11 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private UserV1Dto.SignupRequest validSignupRequest() { - return new UserV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + private AuthV1Dto.SignupRequest validSignupRequest() { + return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); } - private ResponseEntity signup(UserV1Dto.SignupRequest request) { + private ResponseEntity signup(AuthV1Dto.SignupRequest request) { return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); } @@ -48,66 +48,6 @@ private HttpHeaders authHeaders(String loginId, String password) { return headers; } - // ========== 회원가입 ========== - - @DisplayName("POST /api/v1/users/signup") - @Nested - class Signup { - - @DisplayName("유효한 정보로 가입하면, 201 Created 응답을 받는다.") - @Test - void returns201_whenValidRequest() { - // act - ResponseEntity response = signup(validSignupRequest()); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - } - - @DisplayName("중복 로그인 ID로 가입하면, 409 Conflict 응답을 받는다.") - @Test - void returns409_whenDuplicateLoginId() { - // arrange - signup(validSignupRequest()); - - // act - ResponseEntity response = signup(validSignupRequest()); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - } - - @DisplayName("잘못된 비밀번호 형식이면, 400 Bad Request 응답을 받는다.") - @Test - void returns400_whenInvalidPassword() { - // arrange - UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( - "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" - ); - - // act - ResponseEntity response = signup(request); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - - @DisplayName("비밀번호에 생년월일이 포함되면, 400 Bad Request 응답을 받는다.") - @Test - void returns400_whenPasswordContainsBirthDate() { - // arrange - UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( - "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" - ); - - // act - ResponseEntity response = signup(request); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - } - // ========== 내 정보 조회 ========== @DisplayName("GET /api/v1/users/me") @@ -149,72 +89,4 @@ void returns401_whenWrongPassword() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } } - - // ========== 비밀번호 수정 ========== - - @DisplayName("PATCH /api/v1/users/me/password") - @Nested - class ChangePassword { - - @DisplayName("유효한 요청이면, 200 OK 응답을 받는다.") - @Test - void returns200_whenValidRequest() { - // arrange - signup(validSignupRequest()); - HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); - headers.setContentType(MediaType.APPLICATION_JSON); - UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); - - // act - ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - } - - @DisplayName("변경 후 새 비밀번호로 인증되고, 이전 비밀번호로는 실패한다.") - @Test - void authenticatesWithNewPassword_afterChange() { - // arrange - 회원가입 + 비밀번호 변경 - signup(validSignupRequest()); - HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); - headers.setContentType(MediaType.APPLICATION_JSON); - UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); - testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); - - // act - 새 비밀번호로 조회 - HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); - ResponseEntity newPwResponse = - testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(newHeaders), ApiResponse.class); - - // act - 이전 비밀번호로 조회 - HttpHeaders oldHeaders = authHeaders("nahyeon", "Hx7!mK2@"); - ResponseEntity oldPwResponse = - testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(oldHeaders), ApiResponse.class); - - // assert - assertAll( - () -> assertThat(newPwResponse.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(oldPwResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) - ); - } - - @DisplayName("현재 비밀번호와 동일한 새 비밀번호면, 400 Bad Request 응답을 받는다.") - @Test - void returns400_whenSamePassword() { - // arrange - signup(validSignupRequest()); - HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); - headers.setContentType(MediaType.APPLICATION_JSON); - UserV1Dto.ChangePasswordRequest body = new UserV1Dto.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); - - // act - ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - } } From 37120057b3c52d2c2270ff29ef82a2b0b95a4c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 22:02:27 +0900 Subject: [PATCH 11/44] =?UTF-8?q?refactor:=20Domain=EC=97=90=EC=84=9C=20Sp?= =?UTF-8?q?ring=20Security=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(DIP=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/PasswordEncryptor.java | 27 +++++++++++++++++++ .../com/loopers/domain/user/UserService.java | 14 +++++----- .../user/BCryptPasswordEncryptor.java | 21 +++++++++++++++ .../support/config/SecurityConfig.java | 15 ----------- .../loopers/domain/user/UserServiceTest.java | 25 +++++++++-------- 5 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java delete 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/domain/user/PasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java new file mode 100644 index 00000000..432e274f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java @@ -0,0 +1,27 @@ +package com.loopers.domain.user; + +/** + * 비밀번호 암호화 포트 (Domain Layer) + * + * Domain이 인프라(Spring Security)에 의존하지 않도록 추상화한 인터페이스. + * 실제 구현은 Infrastructure 계층의 어댑터가 담당한다. + */ +public interface PasswordEncryptor { + + /** + * 평문 비밀번호를 암호화한다. + * + * @param rawPassword 평문 비밀번호 + * @return 암호화된 비밀번호 + */ + String encode(String rawPassword); + + /** + * 평문 비밀번호와 암호화된 비밀번호가 일치하는지 확인한다. + * + * @param rawPassword 평문 비밀번호 + * @param encodedPassword 암호화된 비밀번호 + * @return 일치 여부 + */ + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 7ab35147..10430f96 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -3,8 +3,6 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -15,7 +13,7 @@ public class UserService { private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; + private final PasswordEncryptor passwordEncryptor; @Transactional public User signup(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { @@ -35,7 +33,7 @@ public User signup(String rawLoginId, String rawPassword, String rawName, String } // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 - String encodedPassword = passwordEncoder.encode(rawPassword); + String encodedPassword = passwordEncryptor.encode(rawPassword); User user = User.create(loginId, encodedPassword, name, birthDate.getValue(), email); return userRepository.save(user); } @@ -45,7 +43,7 @@ public User authenticate(String rawLoginId, String rawPassword) { User user = userRepository.findByLoginId(rawLoginId) .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); - if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + if (!passwordEncryptor.matches(rawPassword, user.getPassword())) { throw new CoreException(UserErrorType.UNAUTHORIZED); } @@ -55,7 +53,7 @@ public User authenticate(String rawLoginId, String rawPassword) { @Transactional public void changePassword(User user, String currentRawPassword, String newRawPassword) { // 현재 비밀번호 확인 - if (!passwordEncoder.matches(currentRawPassword, user.getPassword())) { + if (!passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.PASSWORD_MISMATCH); } @@ -66,11 +64,11 @@ public void changePassword(User user, String currentRawPassword, String newRawPa PasswordPolicy.validate(newRawPassword, user.getBirthDate()); // 동일 비밀번호 확인 - if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + if (passwordEncryptor.matches(newRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.SAME_PASSWORD); } // 암호화 후 변경 - user.changePassword(passwordEncoder.encode(newRawPassword)); + user.changePassword(passwordEncryptor.encode(newRawPassword)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java new file mode 100644 index 00000000..cb296bed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PasswordEncryptor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BCryptPasswordEncryptor implements PasswordEncryptor { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return encoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encoder.matches(rawPassword, encodedPassword); + } +} \ No newline at end of file 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 deleted file mode 100644 index c019a8cd..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.support.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration -public class SecurityConfig { - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 6b3514fc..715e300f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.util.Optional; @@ -25,14 +24,14 @@ public class UserServiceTest { private UserRepository userRepository; - private PasswordEncoder passwordEncoder; + private PasswordEncryptor passwordEncryptor; private UserService userService; @BeforeEach void setUp() { userRepository = Mockito.mock(UserRepository.class); - passwordEncoder = Mockito.mock(PasswordEncoder.class); - userService = new UserService(userRepository, passwordEncoder); + passwordEncryptor = Mockito.mock(PasswordEncryptor.class); + userService = new UserService(userRepository, passwordEncryptor); } @DisplayName("회원가입 시,") @@ -44,7 +43,7 @@ class Signup { void signup_whenValidInput() { // arrange when(userRepository.existsByLoginId(anyString())).thenReturn(false); - when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$encodedHash"); + when(passwordEncryptor.encode(anyString())).thenReturn("$2a$10$encodedHash"); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // act @@ -103,7 +102,7 @@ void returnsUser_whenValidCredentials() { new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // act User result = userService.authenticate("nahyeon", "Hx7!mK2@"); @@ -136,7 +135,7 @@ void throwsException_whenPasswordMismatch() { new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -160,9 +159,9 @@ void changesPassword_whenValidRequest() { new UserName("홍길동"), LocalDate.of(1994, 11, 15), new Email("nahyeon@example.com") ); - when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); - when(passwordEncoder.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); - when(passwordEncoder.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); + when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); + when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); // act userService.changePassword(user, "Hx7!mK2@", "Nw8@pL3#"); @@ -180,7 +179,7 @@ void throwsException_whenCurrentPasswordWrong() { new UserName("홍길동"), LocalDate.of(1994, 11, 15), new Email("nahyeon@example.com") ); - when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -199,8 +198,8 @@ void throwsException_whenSameAsCurrentPassword() { new UserName("홍길동"), LocalDate.of(1994, 11, 15), new Email("nahyeon@example.com") ); - when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // current matches - when(passwordEncoder.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // same check also matches + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // current matches + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // same check also matches // act & assert CoreException exception = assertThrows(CoreException.class, () -> { From 8a07da369ee8da40d425b4e0dc554dd007a2b130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 5 Feb 2026 23:50:54 +0900 Subject: [PATCH 12/44] =?UTF-8?q?chore:=20Example=20=EC=83=98=ED=94=8C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 -- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 --- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ------------------ 13 files changed, 439 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad6..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index 867cd70a..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CommonErrorType; -import com.loopers.support.error.CoreException; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(CommonErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(CommonErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(CommonErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e566..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index ba881f58..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CommonErrorType; -import com.loopers.support.error.CoreException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(CommonErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 91737601..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 2f283840..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CommonErrorType; -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(CommonErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(CommonErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index 78aefec7..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CommonErrorType; -import com.loopers.support.error.CoreException; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(CommonErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} From 13ea51b8356accbee0ec1b0496bea26b67530bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:18:07 +0900 Subject: [PATCH 13/44] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20Detached=20Entity?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20DB=20=EB=AF=B8=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/UserService.java | 5 +++-- .../com/loopers/interfaces/api/auth/AuthV1Controller.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 10430f96..a85f6034 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -58,7 +58,7 @@ public void changePassword(User user, String currentRawPassword, String newRawPa } // 새 비밀번호 규칙 검증 - Password newPassword = Password.of(newRawPassword); + Password.of(newRawPassword); // 교차 검증 PasswordPolicy.validate(newRawPassword, user.getBirthDate()); @@ -68,7 +68,8 @@ public void changePassword(User user, String currentRawPassword, String newRawPa throw new CoreException(UserErrorType.SAME_PASSWORD); } - // 암호화 후 변경 + // 암호화 후 변경 및 저장 (detached 엔티티 대응) user.changePassword(passwordEncryptor.encode(newRawPassword)); + userRepository.save(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index bdc66a73..f766d3ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -24,7 +24,7 @@ public ApiResponse signup(@RequestBody AuthV1Dto.Signu return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); } - @PatchMapping("/password") + @PutMapping("/password") @Override public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginId") String loginId, From f376c3a34e80cba7bb75f19f9e2bbddf304ee7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:18:50 +0900 Subject: [PATCH 14/44] =?UTF-8?q?test:=20AuthFacade,=20UserFacade=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- .../auth/AuthFacadeIntegrationTest.java | 198 ++++++++++++++++++ .../user/UserFacadeIntegrationTest.java | 103 +++++++++ 2 files changed, 301 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java new file mode 100644 index 00000000..070c3696 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -0,0 +1,198 @@ +package com.loopers.application.auth; + +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * [Application Layer - AuthFacade 통합 테스트] + * + * AuthFacade의 비즈니스 흐름을 검증하는 통합 테스트. + * Spring Context를 로드하고 실제 DB를 사용하여 오케스트레이션 로직을 검증한다. + * + * 테스트 범위: + * - AuthFacade → UserService → UserRepository → DB + * - 실제 비즈니스 흐름 전체 검증 + */ +@SpringBootTest +class AuthFacadeIntegrationTest { + + @Autowired + private AuthFacade authFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncryptor passwordEncryptor; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("signup 메서드는") + class Signup { + + @Test + @DisplayName("유효한 정보로 가입하면, UserInfo를 반환한다") + void returnsUserInfoWhenValidInput() { + // arrange + String loginId = "nahyeon"; + String password = "Hx7!mK2@"; + String name = "홍길동"; + String birthDate = "1994-11-15"; + String email = "nahyeon@example.com"; + + // act + UserInfo result = authFacade.signup(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(result.loginId()).isEqualTo("nahyeon"), + () -> assertThat(result.name()).isEqualTo("홍길동"), + () -> assertThat(result.maskedName()).isEqualTo("홍길*"), + () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), + () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") + ); + } + + @Test + @DisplayName("가입 후 DB에 사용자가 저장된다") + void savesUserToDatabase() { + // arrange + String loginId = "testuser"; + String password = "Hx7!mK2@"; + String name = "테스트"; + String birthDate = "1990-01-01"; + String email = "test@example.com"; + + // act + authFacade.signup(loginId, password, name, birthDate, email); + + // assert + assertThat(userRepository.existsByLoginId(loginId)).isTrue(); + } + + @Test + @DisplayName("중복된 로그인ID로 가입하면, 예외가 발생한다") + void throwsExceptionWhenDuplicateLoginId() { + // arrange + String loginId = "duplicate"; + authFacade.signup(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + authFacade.signup(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + } + + @Test + @DisplayName("비밀번호에 생년월일이 포함되면, 예외가 발생한다") + void throwsExceptionWhenPasswordContainsBirthDate() { + // arrange + String loginId = "nahyeon"; + String password = "X19940115!"; // contains birthDate + String name = "홍길동"; + String birthDate = "1994-01-15"; + String email = "nahyeon@example.com"; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + authFacade.signup(loginId, password, name, birthDate, email); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + @Nested + @DisplayName("changePassword 메서드는") + class ChangePassword { + + @Test + @DisplayName("유효한 요청이면, 비밀번호가 변경된다") + void changesPasswordWhenValidRequest() { + // arrange + String loginId = "nahyeon"; + String currentPassword = "Hx7!mK2@"; + String newPassword = "Nw8@pL3#"; + authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act + authFacade.changePassword(loginId, currentPassword, currentPassword, newPassword); + + // assert - 새 비밀번호로 인증 성공 확인 + User user = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(passwordEncryptor.matches(newPassword, user.getPassword())).isTrue(); + } + + @Test + @DisplayName("헤더 비밀번호가 틀리면, 인증 예외가 발생한다") + void throwsExceptionWhenHeaderPasswordWrong() { + // arrange + String loginId = "nahyeon"; + String currentPassword = "Hx7!mK2@"; + authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + authFacade.changePassword(loginId, "wrongPw1!", currentPassword, "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + @DisplayName("현재 비밀번호가 틀리면, 예외가 발생한다") + void throwsExceptionWhenCurrentPasswordWrong() { + // arrange + String loginId = "nahyeon"; + String currentPassword = "Hx7!mK2@"; + authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + authFacade.changePassword(loginId, currentPassword, "wrongPw1!", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면, 예외가 발생한다") + void throwsExceptionWhenSameAsCurrentPassword() { + // arrange + String loginId = "nahyeon"; + String currentPassword = "Hx7!mK2@"; + authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + authFacade.changePassword(loginId, currentPassword, currentPassword, currentPassword); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java new file mode 100644 index 00000000..a91b1077 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java @@ -0,0 +1,103 @@ +package com.loopers.application.user; + +import com.loopers.application.auth.AuthFacade; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * [Application Layer - UserFacade 통합 테스트] + * + * UserFacade의 비즈니스 흐름을 검증하는 통합 테스트. + * Spring Context를 로드하고 실제 DB를 사용하여 오케스트레이션 로직을 검증한다. + * + * 테스트 범위: + * - UserFacade → UserService → UserRepository → DB + * - 실제 비즈니스 흐름 전체 검증 + */ +@SpringBootTest +class UserFacadeIntegrationTest { + + @Autowired + private UserFacade userFacade; + + @Autowired + private AuthFacade authFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("getMyInfo 메서드는") + class GetMyInfo { + + @Test + @DisplayName("유효한 인증 정보면, 사용자 정보를 반환한다") + void returnsUserInfoWhenValidCredentials() { + // arrange + String loginId = "nahyeon"; + String password = "Hx7!mK2@"; + authFacade.signup(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act + UserInfo result = userFacade.getMyInfo(loginId, password); + + // assert + assertAll( + () -> assertThat(result.loginId()).isEqualTo("nahyeon"), + () -> assertThat(result.name()).isEqualTo("홍길동"), + () -> assertThat(result.maskedName()).isEqualTo("홍길*"), + () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), + () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") + ); + } + + @Test + @DisplayName("존재하지 않는 사용자면, 예외가 발생한다") + void throwsExceptionWhenUserNotFound() { + // arrange + String loginId = "nonexistent"; + String password = "Hx7!mK2@"; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userFacade.getMyInfo(loginId, password); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + @DisplayName("비밀번호가 틀리면, 예외가 발생한다") + void throwsExceptionWhenPasswordWrong() { + // arrange + String loginId = "nahyeon"; + String password = "Hx7!mK2@"; + authFacade.signup(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userFacade.getMyInfo(loginId, "wrongPw1!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + } +} \ No newline at end of file From d7addbf8ecc7f482b47a845795b8e43d41b034bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:24:07 +0900 Subject: [PATCH 15/44] =?UTF-8?q?test:=20E2E=20method=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java index 866ca298..b8766bde 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -124,7 +124,7 @@ void returns200_whenValidRequest() { // act ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -138,7 +138,7 @@ void authenticatesWithNewPassword_afterChange() { HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); - testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // act - 새 비밀번호로 조회 HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); @@ -168,7 +168,7 @@ void returns400_whenSamePassword() { // act ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PATCH, new HttpEntity<>(body, headers), ApiResponse.class); + CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); From 84dc3f9d7e5477d24a4da6957602de7126263d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:25:07 +0900 Subject: [PATCH 16/44] =?UTF-8?q?test:=20Repository=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/UserRepositoryIntegrationTest.java | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java new file mode 100644 index 00000000..6cb2b257 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -0,0 +1,167 @@ +package com.loopers.domain.user; + +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 java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * [Infrastructure Layer - Repository 통합 테스트] + * + * UserRepository 인터페이스의 DB 연동을 검증하는 통합 테스트. + * Spring Context를 로드하고 Testcontainers로 실제 MySQL을 사용한다. + * + * 테스트 범위: + * - UserRepository (interface) → UserRepositoryImpl → UserJpaRepository → DB + * - 실제 쿼리 실행 및 데이터 저장/조회 검증 + * + * 테스트 격리: + * - @AfterEach에서 truncateAllTables() 호출 + * - 각 테스트가 독립적으로 실행될 수 있도록 보장 + */ +@SpringBootTest +class UserRepositoryIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /** + * 테스트용 User 엔티티 생성 헬퍼 메서드 + */ + private User createTestUser(String loginIdValue) { + return User.create( + new LoginId(loginIdValue), + "$2a$10$encodedPasswordHash", + new UserName("홍길동"), + LocalDate.of(1994, 11, 15), + new Email(loginIdValue + "@example.com") + ); + } + + @Nested + @DisplayName("save 메서드는") + class Save { + + @Test + @DisplayName("새로운 사용자를 저장하면, ID가 생성된다") + void createsIdWhenSavingNewUser() { + // arrange + User user = createTestUser("testuser"); + + // act + User savedUser = userRepository.save(user); + + // assert + assertThat(savedUser.getId()).isNotNull(); + } + + @Test + @DisplayName("저장된 사용자의 모든 필드가 올바르게 저장된다") + void savesAllFieldsCorrectly() { + // arrange + LoginId loginId = new LoginId("nahyeon"); + String encodedPassword = "$2a$10$encodedPasswordHash"; + UserName name = new UserName("홍길동"); + LocalDate birthDate = LocalDate.of(1994, 11, 15); + Email email = new Email("nahyeon@example.com"); + + User user = User.create(loginId, encodedPassword, name, birthDate, email); + + // act + User savedUser = userRepository.save(user); + + // assert + assertAll( + () -> assertThat(savedUser.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(savedUser.getPassword()).isEqualTo(encodedPassword), + () -> assertThat(savedUser.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(savedUser.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(savedUser.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + } + } + + @Nested + @DisplayName("findByLoginId 메서드는") + class FindByLoginId { + + @Test + @DisplayName("존재하는 로그인ID로 조회하면, 해당 사용자를 반환한다") + void returnsUserWhenLoginIdExists() { + // arrange + User user = createTestUser("existinguser"); + userRepository.save(user); + + // act + Optional result = userRepository.findByLoginId("existinguser"); + + // assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().getLoginId().getValue()).isEqualTo("existinguser") + ); + } + + @Test + @DisplayName("존재하지 않는 로그인ID로 조회하면, empty를 반환한다") + void returnsEmptyWhenLoginIdNotExists() { + // arrange + String nonExistingLoginId = "nonexisting"; + + // act + Optional result = userRepository.findByLoginId(nonExistingLoginId); + + // assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("existsByLoginId 메서드는") + class ExistsByLoginId { + + @Test + @DisplayName("존재하는 로그인ID면, true를 반환한다") + void returnsTrueWhenLoginIdExists() { + // arrange + User user = createTestUser("existinguser"); + userRepository.save(user); + + // act + boolean exists = userRepository.existsByLoginId("existinguser"); + + // assert + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 로그인ID면, false를 반환한다") + void returnsFalseWhenLoginIdNotExists() { + // arrange + String nonExistingLoginId = "nonexisting"; + + // act + boolean exists = userRepository.existsByLoginId(nonExistingLoginId); + + // assert + assertThat(exists).isFalse(); + } + } +} \ No newline at end of file From ba17895f86575a7cd192e18c6c6a7b53765aa9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:39:29 +0900 Subject: [PATCH 17/44] =?UTF-8?q?test:=20=EC=83=88=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EC=97=90=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC=20=ED=8F=AC=ED=95=A8=20=EC=98=88=EC=99=B8=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 --- .../loopers/domain/user/UserServiceTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 715e300f..7fad393c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -208,5 +208,24 @@ void throwsException_whenSameAsCurrentPassword() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenNewPasswordContainsBirthDate() { + // arrange - birthDate: 1990-03-25 (연속 동일 문자 없음) + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), LocalDate.of(1990, 3, 25), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + + // act & assert - newPassword contains "19900325" (birthDate) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword(user, "Hx7!mK2@", "X19900325!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } } } From 67265cd56cdbb54f3b361b173f335a910ad70bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 00:49:33 +0900 Subject: [PATCH 18/44] =?UTF-8?q?chore:=20Redis=20Testcontainers=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/testcontainers/RedisTestContainersConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f0..e637f61e 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -4,9 +4,13 @@ import org.springframework.context.annotation.Configuration; import org.testcontainers.utility.DockerImageName; +import java.time.Duration; + @Configuration public class RedisTestContainersConfig { - private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest")); + private static final RedisContainer redisContainer = new RedisContainer( + DockerImageName.parse("redis:7-alpine").asCompatibleSubstituteFor("redis")) + .withStartupTimeout(Duration.ofMinutes(2)); static { redisContainer.start(); From 5dd5a9dbba567be0bf611078f55ad279a30c949c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 01:13:23 +0900 Subject: [PATCH 19/44] =?UTF-8?q?feat:=20claude.md=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++ .claude/plan.md | 64 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/plan.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..0f01b32f --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +이 파일은 Claude Code가 이 프로젝트를 이해하는 데 필요한 컨텍스트를 제공합니다. + +## 프로젝트 개요 + +Loopers에서 제공하는 Spring + Java 기반 멀티 모듈 템플릿 프로젝트입니다. 커머스 도메인을 위한 API, Batch, Streamer 애플리케이션을 포함합니다. + +## 기술 스택 및 버전 + +### Core +- **Java**: 21 +- **Spring Boot**: 3.4.4 +- **Spring Cloud**: 2024.0.1 +- **Gradle**: 8.13 + +### Data +- **Spring Data JPA** + **QueryDSL** (Jakarta) +- **MySQL** (mysql-connector-j) +- **Spring Data Redis** +- **Spring Kafka** + +### Serialization +- **Jackson** (jackson-datatype-jsr310, jackson-module-kotlin) + +### Monitoring & Logging +- **Micrometer** + **Prometheus** +- **Micrometer Tracing** (Brave) +- **Logback Slack Appender**: 1.6.1 + +### Documentation +- **SpringDoc OpenAPI**: 2.7.0 + +### Testing +- **JUnit 5** (junit-platform-launcher) +- **Mockito**: 5.14.0 +- **SpringMockK**: 4.0.2 +- **Instancio**: 5.0.2 +- **Testcontainers** (MySQL, Redis, Kafka) + +### Build Tools +- **Lombok** +- **JaCoCo** (코드 커버리지) + +## 모듈 구조 + +``` +Root (loopers-java-spring-template) +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api/ # REST API 서버 (Web, Actuator, OpenAPI) +│ ├── commerce-batch/ # Spring Batch 애플리케이션 +│ └── commerce-streamer/ # Kafka Consumer 애플리케이션 +├── modules/ # 재사용 가능한 설정 모듈 +│ ├── jpa/ # JPA + QueryDSL 설정 +│ ├── redis/ # Redis 설정 +│ └── kafka/ # Kafka 설정 +└── supports/ # 부가 기능 애드온 모듈 + ├── jackson/ # Jackson 직렬화 설정 + ├── logging/ # Prometheus + Slack Appender + └── monitoring/ # Micrometer + Prometheus +``` + +### 모듈 의존성 관계 +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring + +## 빌드 및 실행 + +### 로컬 인프라 실행 +```bash +docker-compose -f ./docker/infra-compose.yml up +``` + +### 모니터링 환경 실행 +```bash +docker-compose -f ./docker/monitoring-compose.yml up +# Grafana: http://localhost:3000 (admin/admin) +``` + +### 빌드 +```bash +./gradlew build +``` + +### 테스트 +```bash +./gradlew test +``` +- 테스트는 `test` 프로파일로 실행됨 +- 타임존: `Asia/Seoul` +- Testcontainers로 MySQL, Redis, Kafka 컨테이너 자동 생성 + +### 특정 앱 실행 +```bash +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun +./gradlew :apps:commerce-streamer:bootRun +``` + +## 프로젝트 설정 + +- **그룹**: `com.loopers` +- **버전**: Git hash 기반 자동 생성 +- **패키지 구조**: `com.loopers.*` + +## 주요 패턴 + +### 테스트 패턴 +- `testFixtures` 플러그인 사용 (jpa, redis, kafka 모듈) +- Testcontainers 기반 통합 테스트 +- E2E 테스트: `*E2ETest.java` +- 통합 테스트: `*IntegrationTest.java` + +### 모듈 규칙 +- **apps**: BootJar 활성화, 일반 Jar 비활성화 +- **modules/supports**: 일반 Jar 활성화, BootJar 비활성화 + +### 필수 연관 문서 +- plan.md 필수 참고 \ No newline at end of file diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 00000000..93c37715 --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,64 @@ +# AI 협업 개발 가이드 + +본 문서는 AI 도구(Claude Code)와의 효과적인 협업을 위한 개발 원칙을 정의합니다. + +## 1. 협업 철학 - 증강 코딩 (Augmented Coding) + +AI는 개발자의 역량을 증강하는 도구이며, 의사결정의 주체는 개발자입니다. + +- **제안과 승인**: AI는 방향성과 대안을 제안하고, 개발자가 최종 승인 +- **설계 주도권**: 아키텍처와 설계 결정은 개발자가 주도 +- **중간 개입**: 반복 동작, 미요청 기능 구현, 임의 테스트 삭제 시 개발자가 개입 + +## 2. 개발 방법론 - TDD (Red → Green → Refactor) + +모든 코드는 테스트 주도 개발 방식으로 작성합니다. + +### 2.1 Red Phase +- 요구사항을 만족하는 실패 테스트 케이스 먼저 작성 +- 3A 원칙 준수 (Arrange - Act - Assert) + +### 2.2 Green Phase +- 테스트를 통과하는 최소한의 코드 작성 +- 오버엔지니어링 금지 + +### 2.3 Refactor Phase +- 코드 품질 개선 및 불필요한 코드 제거 +- 객체지향 원칙 준수 +- 모든 테스트 통과 필수 + +## 3. 코드 품질 기준 + +### 3.1 금지 사항 (Never Do) +- Mock 남발: 실제 동작하지 않는 코드, 과도한 Mock 사용 금지 +- null-safety 위반: Optional 활용 필수 (Java) +- 디버깅 코드: println, System.out 등 잔류 금지 + +### 3.2 권장 사항 (Recommendation) +- E2E 테스트로 실제 API 동작 검증 +- 재사용 가능한 객체 설계 +- 성능 최적화 대안 제시 +- 완성된 API는 `http/**/*.http`에 문서화 + +### 3.3 우선순위 (Priority) +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 보장 +3. 테스트 가능한 구조 설계 +4. 기존 코드 패턴과 일관성 유지 + +## 4. Git 컨벤션 + +- **커밋 주체**: 개발자가 직접 수행 (AI 임의 커밋 금지) +- **커밋 메시지**: Conventional Commits 형식 권장 + +## 5. AI 협업 스타일 + +본 프로젝트에서 AI와의 협업은 다음 방식을 지향합니다: + +| 스타일 | 설명 | +|--------|------| +| **Planning-first** | 개발자가 먼저 설계하고, AI로 검증 및 대안 비교 | +| **Explanation-seeking** | 코드의 이유, 원리, 동작에 대한 설명 요구 | +| **Iterative-reasoning** | 문제를 분해하여 추론 → 질문 → 수정 반복 | + +> AI는 답을 제공하는 것이 아닌, 사고를 돕는 도구로 활용합니다. \ No newline at end of file From e04cc0c0051c19d8b74bbf3fa1f3afd1399eb770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 01:14:33 +0900 Subject: [PATCH 20/44] =?UTF-8?q?feat:=20http=20auth,=20user=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/auth-v1.http | 22 ++++++++++++++++++++++ http/commerce-api/user-v1.http | 4 ++++ http/http-client.env.json | 6 ++++-- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 http/commerce-api/auth-v1.http create mode 100644 http/commerce-api/user-v1.http diff --git a/http/commerce-api/auth-v1.http b/http/commerce-api/auth-v1.http new file mode 100644 index 00000000..72893443 --- /dev/null +++ b/http/commerce-api/auth-v1.http @@ -0,0 +1,22 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/auth/signup +Content-Type: application/json + +{ + "loginId": "{{loginId}}", + "password": "{{loginPw}}", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} + +### 비밀번호 변경 +PUT {{commerce-api}}/api/v1/auth/password +Content-Type: application/json +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}} + +{ + "currentPassword": "{{loginPw}}", + "newPassword": "NewPass1234!@" +} \ No newline at end of file diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 00000000..8827c641 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,4 @@ +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}} \ No newline at end of file diff --git a/http/http-client.env.json b/http/http-client.env.json index 0db34e6a..6d7eb143 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -1,5 +1,7 @@ { "local": { - "commerce-api": "http://localhost:8080" + "commerce-api": "http://localhost:8080", + "loginId": "testuser01", + "loginPw": "Test10395!@" } -} +} \ No newline at end of file From 5deb1b7512a5f1a98de53b58d1bbfbe6712ea283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 6 Feb 2026 02:19:51 +0900 Subject: [PATCH 21/44] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=92=88=EC=A7=88=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20stubbing=20=EC=A0=9C=EA=B1=B0,=20=20PasswordPolicy?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=AA=85=ED=99=95=ED=99=94,?= =?UTF-8?q?=20BirthDate=20@Embedded=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/user/UserInfo.java | 2 +- .../loopers/domain/user/PasswordPolicy.java | 6 ++++-- .../java/com/loopers/domain/user/User.java | 10 ++++------ .../com/loopers/domain/user/UserService.java | 4 ++-- .../user/UserRepositoryIntegrationTest.java | 7 +++---- .../loopers/domain/user/UserServiceTest.java | 18 ++++++++---------- .../java/com/loopers/domain/user/UserTest.java | 8 +++----- 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 393a0ef7..43c95ca0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -16,7 +16,7 @@ public static UserInfo from(User user) { user.getLoginId().getValue(), user.getName().getValue(), user.getName().getMaskedValue(), - user.getBirthDate(), + user.getBirthDate().getValue(), user.getEmail().getValue() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java index 986ec36c..9b169ffa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java @@ -7,12 +7,14 @@ import java.time.format.DateTimeFormatter; /** - * 비밀번호 교차 검증 정책 (Domain Service) + * 비밀번호 교차 검증 정책 (Utility Class) * * Password VO 자체로는 판단할 수 없는, 다른 도메인 값과의 관계를 검증한다. * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) */ -public class PasswordPolicy { +public final class PasswordPolicy { + + private PasswordPolicy() {} public static void validate(String rawPassword, LocalDate birthDate) { validateBirthDateNotContained(rawPassword, birthDate); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 2e7ab6f3..03ac39e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -7,8 +7,6 @@ import jakarta.persistence.Table; import lombok.Getter; -import java.time.LocalDate; - @Entity @Table(name = "users") @Getter @@ -23,15 +21,15 @@ public class User extends BaseEntity { @Embedded private UserName name; - @Column(name = "birth_date", nullable = false) - private LocalDate birthDate; + @Embedded + private BirthDate birthDate; @Embedded private Email email; protected User() {} - private User(LoginId loginId, String encodedPassword, UserName name, LocalDate birthDate, Email email) { + private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { this.loginId = loginId; this.password = encodedPassword; this.name = name; @@ -39,7 +37,7 @@ private User(LoginId loginId, String encodedPassword, UserName name, LocalDate b this.email = email; } - public static User create(LoginId loginId, String encodedPassword, UserName name, LocalDate birthDate, Email email) { + public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { return new User(loginId, encodedPassword, name, birthDate, email); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index a85f6034..53ccce7f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -34,7 +34,7 @@ public User signup(String rawLoginId, String rawPassword, String rawName, String // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 String encodedPassword = passwordEncryptor.encode(rawPassword); - User user = User.create(loginId, encodedPassword, name, birthDate.getValue(), email); + User user = User.create(loginId, encodedPassword, name, birthDate, email); return userRepository.save(user); } @@ -61,7 +61,7 @@ public void changePassword(User user, String currentRawPassword, String newRawPa Password.of(newRawPassword); // 교차 검증 - PasswordPolicy.validate(newRawPassword, user.getBirthDate()); + PasswordPolicy.validate(newRawPassword, user.getBirthDate().getValue()); // 동일 비밀번호 확인 if (passwordEncryptor.matches(newRawPassword, user.getPassword())) { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java index 6cb2b257..18bb6d5d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.time.LocalDate; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -50,7 +49,7 @@ private User createTestUser(String loginIdValue) { new LoginId(loginIdValue), "$2a$10$encodedPasswordHash", new UserName("홍길동"), - LocalDate.of(1994, 11, 15), + new BirthDate("1994-11-15"), new Email(loginIdValue + "@example.com") ); } @@ -79,7 +78,7 @@ void savesAllFieldsCorrectly() { LoginId loginId = new LoginId("nahyeon"); String encodedPassword = "$2a$10$encodedPasswordHash"; UserName name = new UserName("홍길동"); - LocalDate birthDate = LocalDate.of(1994, 11, 15); + BirthDate birthDate = new BirthDate("1994-11-15"); Email email = new Email("nahyeon@example.com"); User user = User.create(loginId, encodedPassword, name, birthDate, email); @@ -92,7 +91,7 @@ void savesAllFieldsCorrectly() { () -> assertThat(savedUser.getLoginId().getValue()).isEqualTo("nahyeon"), () -> assertThat(savedUser.getPassword()).isEqualTo(encodedPassword), () -> assertThat(savedUser.getName().getValue()).isEqualTo("홍길동"), - () -> assertThat(savedUser.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(savedUser.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), () -> assertThat(savedUser.getEmail().getValue()).isEqualTo("nahyeon@example.com") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 7fad393c..a78f7889 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.time.LocalDate; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -54,7 +53,7 @@ void signup_whenValidInput() { () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), () -> assertThat(user.getPassword()).isEqualTo("$2a$10$encodedHash"), () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), - () -> assertThat(user.getBirthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), + () -> assertThat(user.getBirthDate().getValue()).isEqualTo(java.time.LocalDate.of(1994, 11, 15)), () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") ); } @@ -98,7 +97,7 @@ void returnsUser_whenValidCredentials() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", - new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new UserName("홍길동"), new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); @@ -131,7 +130,7 @@ void throwsException_whenPasswordMismatch() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", - new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new UserName("홍길동"), new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); @@ -156,7 +155,7 @@ void changesPassword_whenValidRequest() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", - new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new UserName("홍길동"), new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); @@ -176,7 +175,7 @@ void throwsException_whenCurrentPasswordWrong() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", - new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new UserName("홍길동"), new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); @@ -195,11 +194,10 @@ void throwsException_whenSameAsCurrentPassword() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", - new UserName("홍길동"), LocalDate.of(1994, 11, 15), + new UserName("홍길동"), new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); - when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // current matches - when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // same check also matches + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -215,7 +213,7 @@ void throwsException_whenNewPasswordContainsBirthDate() { // arrange - birthDate: 1990-03-25 (연속 동일 문자 없음) User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", - new UserName("홍길동"), LocalDate.of(1990, 3, 25), + new UserName("홍길동"), new BirthDate("1990-03-25"), new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index b0148bab..1339f055 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -4,8 +4,6 @@ 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; @@ -25,7 +23,7 @@ void createsUser_whenValidInput() { LoginId loginId = new LoginId("nahyeon"); String encodedPassword = "$2a$10$encodedPasswordHash"; UserName name = new UserName("홍길동"); - LocalDate birthDate = LocalDate.of(1994, 11, 15); + BirthDate birthDate = new BirthDate("1994-11-15"); Email email = new Email("nahyeon@example.com"); // act @@ -36,7 +34,7 @@ void createsUser_whenValidInput() { () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), () -> assertThat(user.getPassword()).isEqualTo(encodedPassword), () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), - () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") ); } @@ -54,7 +52,7 @@ void changesPassword_whenNewEncodedPasswordGiven() { new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), - LocalDate.of(1994, 11, 15), + new BirthDate("1994-11-15"), new Email("nahyeon@example.com") ); String newEncodedPassword = "$2a$10$newHash"; From ada08d197700976616b7891c9aec7c4ce6553ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 15:48:00 +0900 Subject: [PATCH 22/44] =?UTF-8?q?fix:=20CoreException=EC=97=90=20cause=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=EC=9D=84=20=EC=A7=80=EC=9B=90=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=98=88=EC=99=B8=20=EC=B2=B4=EC=9D=B8=20=EC=9C=A0?= =?UTF-8?q?=EC=8B=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/user/BirthDate.java | 2 +- .../com/loopers/support/error/CoreException.java | 8 ++++++-- .../java/com/loopers/domain/user/BirthDateTest.java | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java index d33a9b4e..6220eb78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -50,7 +50,7 @@ private static LocalDate parseDate(String rawValue) { return LocalDate.parse(rawValue); } catch (DateTimeParseException e) { throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, - "생년월일은 YYYY-MM-DD 형식이어야 합니다."); + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6..cb7d6aaf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -8,11 +8,15 @@ public class CoreException extends RuntimeException { private final String customMessage; public CoreException(ErrorType errorType) { - this(errorType, null); + this(errorType, null, null); } public CoreException(ErrorType errorType, String customMessage) { - super(customMessage != null ? customMessage : errorType.getMessage()); + this(errorType, customMessage, null); + } + + public CoreException(ErrorType errorType, String customMessage, Throwable cause) { + super(customMessage != null ? customMessage : errorType.getMessage(), cause); this.errorType = errorType; this.customMessage = customMessage; } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index cfe35111..37a10160 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -173,5 +174,16 @@ void throwsException_whenNonLeapYearFeb29() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } + + @DisplayName("잘못된 형식이면, 예외의 원인으로 DateTimeParseException을 포함한다.") + @Test + void preservesCauseWhenInvalidFormat() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + + assertThat(exception.getCause()).isInstanceOf(DateTimeParseException.class); + } } } From c1a73645b71a6d258e2d47ece64bec4bc860cde3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 16:11:36 +0900 Subject: [PATCH 23/44] =?UTF-8?q?refactor:=20Lombok=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20+=20this.=20=EC=A0=91=EB=91=90=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 --- .../loopers/application/auth/AuthFacade.java | 12 +++++---- .../loopers/application/user/UserFacade.java | 8 +++--- .../java/com/loopers/domain/user/User.java | 22 +++++++++++++-- .../com/loopers/domain/user/UserService.java | 27 ++++++++++--------- .../user/BCryptPasswordEncryptor.java | 4 +-- .../user/UserRepositoryImpl.java | 12 +++++---- .../interfaces/api/ApiControllerAdvice.java | 6 +++-- .../interfaces/api/auth/AuthV1Controller.java | 10 ++++--- .../interfaces/api/user/UserV1Controller.java | 8 +++--- .../support/error/CommonErrorType.java | 25 ++++++++++++++--- .../loopers/support/error/CoreException.java | 11 +++++--- .../loopers/support/error/UserErrorType.java | 25 ++++++++++++++--- .../java/com/loopers/domain/BaseEntity.java | 18 +++++++++++-- 13 files changed, 136 insertions(+), 52 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java index 4560cb1c..200e3d1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -3,21 +3,23 @@ import com.loopers.application.user.UserInfo; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -@RequiredArgsConstructor @Component public class AuthFacade { private final UserService userService; + public AuthFacade(UserService userService) { + this.userService = userService; + } + public UserInfo signup(String loginId, String password, String name, String birthDate, String email) { - User user = userService.signup(loginId, password, name, birthDate, email); + User user = this.userService.signup(loginId, password, name, birthDate, email); return UserInfo.from(user); } public void changePassword(String loginId, String headerPassword, String currentPassword, String newPassword) { - User user = userService.authenticate(loginId, headerPassword); - userService.changePassword(user, currentPassword, newPassword); + User user = this.userService.authenticate(loginId, headerPassword); + this.userService.changePassword(user, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index f5a49282..28a8a4db 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -2,16 +2,18 @@ import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -@RequiredArgsConstructor @Component public class UserFacade { private final UserService userService; + public UserFacade(UserService userService) { + this.userService = userService; + } + public UserInfo getMyInfo(String loginId, String password) { - User user = userService.authenticate(loginId, password); + User user = this.userService.authenticate(loginId, password); return UserInfo.from(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 03ac39e3..fd6c3fab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -5,11 +5,9 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; -import lombok.Getter; @Entity @Table(name = "users") -@Getter public class User extends BaseEntity { @Embedded @@ -44,4 +42,24 @@ public static User create(LoginId loginId, String encodedPassword, UserName name public void changePassword(String newEncodedPassword) { this.password = newEncodedPassword; } + + public LoginId getLoginId() { + return this.loginId; + } + + public String getPassword() { + return this.password; + } + + public UserName getName() { + return this.name; + } + + public BirthDate getBirthDate() { + return this.birthDate; + } + + public Email getEmail() { + return this.email; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 53ccce7f..c14e8ccb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -2,19 +2,20 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; - -@RequiredArgsConstructor @Component public class UserService { private final UserRepository userRepository; private final PasswordEncryptor passwordEncryptor; + public UserService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { + this.userRepository = userRepository; + this.passwordEncryptor = passwordEncryptor; + } + @Transactional public User signup(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { // 1. VO 생성 (각 VO가 자체 규칙 검증) @@ -28,22 +29,22 @@ public User signup(String rawLoginId, String rawPassword, String rawName, String PasswordPolicy.validate(rawPassword, birthDate.getValue()); // 3. 중복 ID 검증 - if (userRepository.existsByLoginId(loginId.getValue())) { + if (this.userRepository.existsByLoginId(loginId.getValue())) { throw new CoreException(UserErrorType.DUPLICATE_LOGIN_ID); } // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 - String encodedPassword = passwordEncryptor.encode(rawPassword); + String encodedPassword = this.passwordEncryptor.encode(rawPassword); User user = User.create(loginId, encodedPassword, name, birthDate, email); - return userRepository.save(user); + return this.userRepository.save(user); } @Transactional(readOnly = true) public User authenticate(String rawLoginId, String rawPassword) { - User user = userRepository.findByLoginId(rawLoginId) + User user = this.userRepository.findByLoginId(rawLoginId) .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); - if (!passwordEncryptor.matches(rawPassword, user.getPassword())) { + if (!this.passwordEncryptor.matches(rawPassword, user.getPassword())) { throw new CoreException(UserErrorType.UNAUTHORIZED); } @@ -53,7 +54,7 @@ public User authenticate(String rawLoginId, String rawPassword) { @Transactional public void changePassword(User user, String currentRawPassword, String newRawPassword) { // 현재 비밀번호 확인 - if (!passwordEncryptor.matches(currentRawPassword, user.getPassword())) { + if (!this.passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.PASSWORD_MISMATCH); } @@ -64,12 +65,12 @@ public void changePassword(User user, String currentRawPassword, String newRawPa PasswordPolicy.validate(newRawPassword, user.getBirthDate().getValue()); // 동일 비밀번호 확인 - if (passwordEncryptor.matches(newRawPassword, user.getPassword())) { + if (this.passwordEncryptor.matches(newRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.SAME_PASSWORD); } // 암호화 후 변경 및 저장 (detached 엔티티 대응) - user.changePassword(passwordEncryptor.encode(newRawPassword)); - userRepository.save(user); + user.changePassword(this.passwordEncryptor.encode(newRawPassword)); + this.userRepository.save(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java index cb296bed..d8f9daae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java @@ -11,11 +11,11 @@ public class BCryptPasswordEncryptor implements PasswordEncryptor { @Override public String encode(String rawPassword) { - return encoder.encode(rawPassword); + return this.encoder.encode(rawPassword); } @Override public boolean matches(String rawPassword, String encodedPassword) { - return encoder.matches(rawPassword, encodedPassword); + return this.encoder.matches(rawPassword, encodedPassword); } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 4768ffe8..bdb40ac0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -2,28 +2,30 @@ import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.Optional; -@RequiredArgsConstructor @Component public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; + public UserRepositoryImpl(UserJpaRepository userJpaRepository) { + this.userJpaRepository = userJpaRepository; + } + @Override public User save(User user) { - return userJpaRepository.save(user); + return this.userJpaRepository.save(user); } @Override public Optional findByLoginId(String loginId) { - return userJpaRepository.findByLoginIdValue(loginId); + return this.userJpaRepository.findByLoginIdValue(loginId); } @Override public boolean existsByLoginId(String loginId) { - return userJpaRepository.existsByLoginIdValue(loginId); + return this.userJpaRepository.existsByLoginIdValue(loginId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 911b6eb9..7da9195d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -6,7 +6,8 @@ import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -22,8 +23,9 @@ import java.util.stream.Collectors; @RestControllerAdvice -@Slf4j public class ApiControllerAdvice { + + private static final Logger log = LoggerFactory.getLogger(ApiControllerAdvice.class); @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index f766d3ee..40d46f54 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -3,22 +3,24 @@ import com.loopers.application.auth.AuthFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; -@RequiredArgsConstructor @RestController @RequestMapping("/api/v1/auth") public class AuthV1Controller implements AuthV1ApiSpec { private final AuthFacade authFacade; + public AuthV1Controller(AuthFacade authFacade) { + this.authFacade = authFacade; + } + @PostMapping("/signup") @ResponseStatus(HttpStatus.CREATED) @Override public ApiResponse signup(@RequestBody AuthV1Dto.SignupRequest request) { - UserInfo info = authFacade.signup( + UserInfo info = this.authFacade.signup( request.loginId(), request.password(), request.name(), request.birthDate(), request.email() ); return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); @@ -31,7 +33,7 @@ public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginPw") String loginPw, @RequestBody AuthV1Dto.ChangePasswordRequest request ) { - authFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); + this.authFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 4419c795..d9ac2ada 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -3,23 +3,25 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -@RequiredArgsConstructor @RestController @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; + public UserV1Controller(UserFacade userFacade) { + this.userFacade = userFacade; + } + @GetMapping("/me") @Override public ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - UserInfo info = userFacade.getMyInfo(loginId, loginPw); + UserInfo info = this.userFacade.getMyInfo(loginId, loginPw); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java index 3410657d..a5d64348 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -1,11 +1,7 @@ package com.loopers.support.error; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@Getter -@RequiredArgsConstructor public enum CommonErrorType implements ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), @@ -16,4 +12,25 @@ public enum CommonErrorType implements ErrorType { private final HttpStatus status; private final String code; private final String message; + + CommonErrorType(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.message; + } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index cb7d6aaf..dd373914 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -1,8 +1,5 @@ package com.loopers.support.error; -import lombok.Getter; - -@Getter public class CoreException extends RuntimeException { private final ErrorType errorType; private final String customMessage; @@ -20,4 +17,12 @@ public CoreException(ErrorType errorType, String customMessage, Throwable cause) this.errorType = errorType; this.customMessage = customMessage; } + + public ErrorType getErrorType() { + return this.errorType; + } + + public String getCustomMessage() { + return this.customMessage; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java index c44962ff..b7e6e743 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -1,11 +1,7 @@ package com.loopers.support.error; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@Getter -@RequiredArgsConstructor public enum UserErrorType implements ErrorType { // 400 Bad Request INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "USER_001", "로그인 ID 형식이 올바르지 않습니다."), @@ -29,4 +25,25 @@ public enum UserErrorType implements ErrorType { private final HttpStatus status; private final String code; private final String message; + + UserErrorType(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.message; + } } \ No newline at end of file diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java index d15a9c76..f80ab1ab 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -7,7 +7,6 @@ import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import lombok.Getter; import java.time.ZonedDateTime; /** @@ -15,7 +14,6 @@ * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다. */ @MappedSuperclass -@Getter public abstract class BaseEntity { @Id @@ -70,4 +68,20 @@ public void restore() { this.deletedAt = null; } } + + public Long getId() { + return this.id; + } + + public ZonedDateTime getCreatedAt() { + return this.createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return this.updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return this.deletedAt; + } } From 371402aa76eb37e399a677b394cb28166025fc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 16:13:30 +0900 Subject: [PATCH 24/44] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/AuthFacadeIntegrationTest.java | 29 ++++----- .../user/UserFacadeIntegrationTest.java | 14 ++-- .../loopers/domain/user/BirthDateTest.java | 42 +++++------- .../com/loopers/domain/user/EmailTest.java | 39 +++++------ .../com/loopers/domain/user/LoginIdTest.java | 65 +++++-------------- .../domain/user/PasswordPolicyTest.java | 15 ++--- .../com/loopers/domain/user/PasswordTest.java | 53 ++++++--------- .../com/loopers/domain/user/UserNameTest.java | 48 +++++--------- .../user/UserRepositoryIntegrationTest.java | 23 +++---- .../loopers/domain/user/UserServiceTest.java | 33 ++++------ .../com/loopers/domain/user/UserTest.java | 9 +-- .../interfaces/api/AuthV1ApiE2ETest.java | 24 +++---- .../interfaces/api/UserV1ApiE2ETest.java | 9 +-- .../support/error/CoreExceptionTest.java | 11 ++-- 14 files changed, 159 insertions(+), 255 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java index 070c3696..904c0526 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -7,6 +7,8 @@ import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,6 +31,7 @@ * - 실제 비즈니스 흐름 전체 검증 */ @SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class AuthFacadeIntegrationTest { @Autowired @@ -53,8 +56,7 @@ void tearDown() { class Signup { @Test - @DisplayName("유효한 정보로 가입하면, UserInfo를 반환한다") - void returnsUserInfoWhenValidInput() { + void 유효한_정보로_가입하면_UserInfo를_반환한다() { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; @@ -76,8 +78,7 @@ void returnsUserInfoWhenValidInput() { } @Test - @DisplayName("가입 후 DB에 사용자가 저장된다") - void savesUserToDatabase() { + void 가입_후_DB에_사용자가_저장된다() { // arrange String loginId = "testuser"; String password = "Hx7!mK2@"; @@ -93,8 +94,7 @@ void savesUserToDatabase() { } @Test - @DisplayName("중복된 로그인ID로 가입하면, 예외가 발생한다") - void throwsExceptionWhenDuplicateLoginId() { + void 중복된_로그인ID로_가입하면_예외가_발생한다() { // arrange String loginId = "duplicate"; authFacade.signup(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); @@ -108,8 +108,7 @@ void throwsExceptionWhenDuplicateLoginId() { } @Test - @DisplayName("비밀번호에 생년월일이 포함되면, 예외가 발생한다") - void throwsExceptionWhenPasswordContainsBirthDate() { + void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { // arrange String loginId = "nahyeon"; String password = "X19940115!"; // contains birthDate @@ -131,8 +130,7 @@ void throwsExceptionWhenPasswordContainsBirthDate() { class ChangePassword { @Test - @DisplayName("유효한 요청이면, 비밀번호가 변경된다") - void changesPasswordWhenValidRequest() { + void 유효한_요청이면_비밀번호가_변경된다() { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; @@ -148,8 +146,7 @@ void changesPasswordWhenValidRequest() { } @Test - @DisplayName("헤더 비밀번호가 틀리면, 인증 예외가 발생한다") - void throwsExceptionWhenHeaderPasswordWrong() { + void 헤더_비밀번호가_틀리면_인증_예외가_발생한다() { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; @@ -164,8 +161,7 @@ void throwsExceptionWhenHeaderPasswordWrong() { } @Test - @DisplayName("현재 비밀번호가 틀리면, 예외가 발생한다") - void throwsExceptionWhenCurrentPasswordWrong() { + void 현재_비밀번호가_틀리면_예외가_발생한다() { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; @@ -180,8 +176,7 @@ void throwsExceptionWhenCurrentPasswordWrong() { } @Test - @DisplayName("새 비밀번호가 현재와 동일하면, 예외가 발생한다") - void throwsExceptionWhenSameAsCurrentPassword() { + void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; @@ -195,4 +190,4 @@ void throwsExceptionWhenSameAsCurrentPassword() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java index a91b1077..2f3e8271 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java @@ -6,6 +6,8 @@ import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -28,6 +30,7 @@ * - 실제 비즈니스 흐름 전체 검증 */ @SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class UserFacadeIntegrationTest { @Autowired @@ -49,8 +52,7 @@ void tearDown() { class GetMyInfo { @Test - @DisplayName("유효한 인증 정보면, 사용자 정보를 반환한다") - void returnsUserInfoWhenValidCredentials() { + void 유효한_인증_정보면_사용자_정보를_반환한다() { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; @@ -70,8 +72,7 @@ void returnsUserInfoWhenValidCredentials() { } @Test - @DisplayName("존재하지 않는 사용자면, 예외가 발생한다") - void throwsExceptionWhenUserNotFound() { + void 존재하지_않는_사용자면_예외가_발생한다() { // arrange String loginId = "nonexistent"; String password = "Hx7!mK2@"; @@ -85,8 +86,7 @@ void throwsExceptionWhenUserNotFound() { } @Test - @DisplayName("비밀번호가 틀리면, 예외가 발생한다") - void throwsExceptionWhenPasswordWrong() { + void 비밀번호가_틀리면_예외가_발생한다() { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; @@ -100,4 +100,4 @@ void throwsExceptionWhenPasswordWrong() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index 37a10160..c6163b60 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,6 +24,7 @@ * - 실제 존재하는 날짜 * - 만 14세 이상 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class BirthDateTest { @DisplayName("생년월일을 생성할 때,") @@ -30,9 +33,8 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("유효한 날짜이면, 정상적으로 생성된다.") @Test - void createsBirthDate_whenValid() { + void 유효한_날짜이면_정상적으로_생성된다() { // arrange String value = "1994-11-15"; @@ -43,9 +45,8 @@ void createsBirthDate_whenValid() { assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1994, 11, 15)); } - @DisplayName("윤년 2월 29일이면, 정상적으로 생성된다.") @Test - void createsBirthDate_whenLeapYear() { + void 윤년_2월_29일이면_정상적으로_생성된다() { // arrange String value = "2000-02-29"; @@ -56,9 +57,8 @@ void createsBirthDate_whenLeapYear() { assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(2000, 2, 29)); } - @DisplayName("최소 허용 날짜(1900-01-01)이면, 정상적으로 생성된다.") @Test - void createsBirthDate_whenMinDate() { + void 최소_허용_날짜이면_정상적으로_생성된다() { // arrange String value = "1900-01-01"; @@ -69,9 +69,8 @@ void createsBirthDate_whenMinDate() { assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1900, 1, 1)); } - @DisplayName("만 14세 경계(정확히 14세)이면, 정상적으로 생성된다.") @Test - void createsBirthDate_whenExactlyMinAge() { + void 만_14세_경계이면_정상적으로_생성된다() { // arrange String value = LocalDate.now().minusYears(14).format(DateTimeFormatter.ISO_LOCAL_DATE); @@ -84,9 +83,8 @@ void createsBirthDate_whenExactlyMinAge() { // ========== 엣지 케이스 ========== - @DisplayName("null이면, 예외가 발생한다.") @Test - void throwsException_whenNull() { + void null이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate(null); @@ -95,9 +93,8 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("잘못된 형식(슬래시)이면, 예외가 발생한다.") @Test - void throwsException_whenSlashFormat() { + void 잘못된_형식_슬래시이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1994/11/15"); @@ -106,9 +103,8 @@ void throwsException_whenSlashFormat() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("잘못된 형식(구분자 없음)이면, 예외가 발생한다.") @Test - void throwsException_whenNoSeparator() { + void 잘못된_형식_구분자_없음이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("19941115"); @@ -117,9 +113,8 @@ void throwsException_whenNoSeparator() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("존재하지 않는 날짜(2월 30일)이면, 예외가 발생한다.") @Test - void throwsException_whenInvalidDate() { + void 존재하지_않는_날짜이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1994-02-30"); @@ -128,9 +123,8 @@ void throwsException_whenInvalidDate() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("미래 날짜이면, 예외가 발생한다.") @Test - void throwsException_whenFutureDate() { + void 미래_날짜이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("2030-01-01"); @@ -139,9 +133,8 @@ void throwsException_whenFutureDate() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("1900년 이전이면, 예외가 발생한다.") @Test - void throwsException_whenTooOld() { + void 1900년_이전이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1899-12-31"); @@ -150,9 +143,8 @@ void throwsException_whenTooOld() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("만 14세 미만이면, 예외가 발생한다.") @Test - void throwsException_whenUnderMinAge() { + void 만_14세_미만이면_예외가_발생한다() { // arrange String value = LocalDate.now().minusYears(13).format(DateTimeFormatter.ISO_LOCAL_DATE); @@ -164,9 +156,8 @@ void throwsException_whenUnderMinAge() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("비윤년 2월 29일이면, 예외가 발생한다.") @Test - void throwsException_whenNonLeapYearFeb29() { + void 비윤년_2월_29일이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1999-02-29"); @@ -175,9 +166,8 @@ void throwsException_whenNonLeapYearFeb29() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); } - @DisplayName("잘못된 형식이면, 예외의 원인으로 DateTimeParseException을 포함한다.") @Test - void preservesCauseWhenInvalidFormat() { + void 잘못된_형식이면_예외의_원인으로_DateTimeParseException을_포함한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1994/11/15"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index c1224fa3..391d4f05 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -19,6 +21,7 @@ * - 공백 불허 * - 연속 점 불허 (로컬 파트) */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class EmailTest { @DisplayName("이메일을 생성할 때,") @@ -27,9 +30,8 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("유효한 이메일이면, 정상적으로 생성된다.") @Test - void createsEmail_whenValid() { + void 유효한_이메일이면_정상적으로_생성된다() { // arrange String value = "nahyeon@example.com"; @@ -40,9 +42,8 @@ void createsEmail_whenValid() { assertThat(email.getValue()).isEqualTo(value); } - @DisplayName("서브도메인이 있으면, 정상적으로 생성된다.") @Test - void createsEmail_whenSubdomain() { + void 서브도메인이_있으면_정상적으로_생성된다() { // arrange String value = "nahyeon@mail.example.com"; @@ -53,9 +54,8 @@ void createsEmail_whenSubdomain() { assertThat(email.getValue()).isEqualTo(value); } - @DisplayName("+ 기호가 포함되면, 정상적으로 생성된다.") @Test - void createsEmail_whenPlusSign() { + void 플러스_기호가_포함되면_정상적으로_생성된다() { // arrange String value = "nahyeon+tag@example.com"; @@ -68,9 +68,8 @@ void createsEmail_whenPlusSign() { // ========== 엣지 케이스 ========== - @DisplayName("null이면, 예외가 발생한다.") @Test - void throwsException_whenNull() { + void null이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email(null); @@ -79,9 +78,8 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("빈 문자열이면, 예외가 발생한다.") @Test - void throwsException_whenEmpty() { + void 빈_문자열이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email(""); @@ -90,9 +88,8 @@ void throwsException_whenEmpty() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("@가 없으면, 예외가 발생한다.") @Test - void throwsException_whenNoAtSign() { + void 골뱅이가_없으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("nahyeonexample.com"); @@ -101,9 +98,8 @@ void throwsException_whenNoAtSign() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("도메인이 없으면, 예외가 발생한다.") @Test - void throwsException_whenNoDomain() { + void 도메인이_없으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("nahyeon@"); @@ -112,9 +108,8 @@ void throwsException_whenNoDomain() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("로컬 파트가 없으면, 예외가 발생한다.") @Test - void throwsException_whenNoLocalPart() { + void 로컬_파트가_없으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("@example.com"); @@ -123,9 +118,8 @@ void throwsException_whenNoLocalPart() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("연속 점이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenConsecutiveDots() { + void 연속_점이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("nahyeon..lim@example.com"); @@ -134,9 +128,8 @@ void throwsException_whenConsecutiveDots() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("공백이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpace() { + void 공백이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("nahyeon lim@example.com"); @@ -145,9 +138,8 @@ void throwsException_whenContainsSpace() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("256자를 초과하면, 예외가 발생한다.") @Test - void throwsException_whenExceedsMaxLength() { + void 256자를_초과하면_예외가_발생한다() { // arrange String value = "a".repeat(250) + "@b.com"; // 256자 @@ -159,9 +151,8 @@ void throwsException_whenExceedsMaxLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } - @DisplayName("한글이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsKorean() { + void 한글이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new Email("홍길동@example.com"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index 75ad425b..f6490bb4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -3,38 +3,14 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -/** - - * Red Phase - - * 1. 먼저 유효한 ID 생성 성공 테스트 작성 Happy Case - - * 2. 테스트 실행 → 컴파일 에러 확인 (LoginId 클래스 없음) - - * 3. Red 상태를 진행해봄! - 컴파일 에러 발생 근데 일단 이 컴파일 에러는 TDD에서의 시작이라 - - * 시작이 중요한게 아니라 테스트 케이스를 작성한 다음에 메인 코드가 작성이 잘되는게 테스트 코드 작성의 꽃이다. - - * 테스트 케이스를 작성하다보면 너무 많아지는데 이 상황에서는 AB CD DE ... 무수히 많은 테스트 코드 - - * 그래서 깔끔하고 응집도 높게 집중할 수 있는 테스트 케이스를 작성하고 검증할 수 있는 테스트 케이스를 작성하면 객체 지향을 준수한 - - * 메인 테스트 코드를 만들 수 있다고 생각한다. 테스트 코드의 장점이다. - - * 테스트 커버리지ㄴ를 올리기보단 .. - - * 객체지향적 프로그래밍을 고려해서 테스트 코드를 진행하고싶음 - + * LoginId Value Object 단위 테스트 - * - - * 스순환참조가 발생될 수 있는데 서비스간 함수를 두자 상위 레이어로 파사드란 레이어를 위치한다. - - * A 파사드는 A 서비스를 호출하면서도 의존하면서도 서비스를 의존받을 수 있다. - - * 파사드 패턴이란 무엇일가 - - * - - * 테스트 코드를 작성하기 이전에 설계부터 진행하는게 맞다고 생각해서 일다 나는 설계부터 진행 - - * - - * 검증 로직은 아직 넣지 않음 - - * - - * 껍데기 단계에서는 단순 할당만 수행 - - * → 테스트 작성 후 Red 상태 확인 - - * → 그 다음 검증 로직 추가 (Green) - - /** * LoginId Value Object 단위 테스트 * @@ -43,6 +19,7 @@ * - 4~20자 * - 영문으로 시작 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class LoginIdTest { @DisplayName("로그인 ID를 생성할 때,") @@ -51,9 +28,8 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("최소 길이(4자) 영문이면, 정상적으로 생성된다.") @Test - void createsLoginId_whenMinLength() { + void 최소_길이_4자_영문이면_정상적으로_생성된다() { // arrange String value = "nahyeon"; @@ -64,9 +40,8 @@ void createsLoginId_whenMinLength() { assertThat(loginId.getValue()).isEqualTo(value); } - @DisplayName("최대 길이(20자)이면, 정상적으로 생성된다.") @Test - void createsLoginId_whenMaxLength() { + void 최대_길이_20자이면_정상적으로_생성된다() { // arrange String value = "abcdefghij1234567890"; // 20자 @@ -77,9 +52,8 @@ void createsLoginId_whenMaxLength() { assertThat(loginId.getValue()).isEqualTo(value); } - @DisplayName("영문 대소문자 + 숫자 조합이면, 정상적으로 생성된다.") @Test - void createsLoginId_whenAlphanumeric() { + void 영문_대소문자_숫자_조합이면_정상적으로_생성된다() { // arrange String value = "nahyeon123"; @@ -92,9 +66,8 @@ void createsLoginId_whenAlphanumeric() { // ========== 엣지 케이스 ========== - @DisplayName("null이면, 예외가 발생한다.") @Test - void throwsException_whenNull() { + void null이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId(null); @@ -103,9 +76,8 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("빈 문자열이면, 예외가 발생한다.") @Test - void throwsException_whenEmpty() { + void 빈_문자열이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId(""); @@ -114,9 +86,8 @@ void throwsException_whenEmpty() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("공백만 있으면, 예외가 발생한다.") @Test - void throwsException_whenBlank() { + void 공백만_있으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId(" "); @@ -125,9 +96,8 @@ void throwsException_whenBlank() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("3자(최소 미만)이면, 예외가 발생한다.") @Test - void throwsException_whenLessThanMinLength() { + void 3자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("abc"); @@ -136,9 +106,8 @@ void throwsException_whenLessThanMinLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("21자(최대 초과)이면, 예외가 발생한다.") @Test - void throwsException_whenExceedsMaxLength() { + void 21자_최대_초과이면_예외가_발생한다() { // arrange String value = "abcdefghij12345678901"; // 21자 @@ -150,9 +119,8 @@ void throwsException_whenExceedsMaxLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("특수문자가 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpecialCharacter() { + void 특수문자가_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("nahyeon@123"); @@ -161,9 +129,8 @@ void throwsException_whenContainsSpecialCharacter() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("한글이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsKorean() { + void 한글이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("nahyeon홍"); @@ -172,9 +139,8 @@ void throwsException_whenContainsKorean() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("숫자로 시작하면, 예외가 발생한다.") @Test - void throwsException_whenStartsWithNumber() { + void 숫자로_시작하면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("123nahyeon"); @@ -183,9 +149,8 @@ void throwsException_whenStartsWithNumber() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); } - @DisplayName("공백이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpace() { + void 공백이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("nahyeon Lim"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java index e0ea7b13..08760816 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,6 +20,7 @@ * 검증 규칙: * - 비밀번호에 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class PasswordPolicyTest { private static final LocalDate BIRTH_DATE = LocalDate.of(1994, 11, 15); @@ -26,15 +29,13 @@ public class PasswordPolicyTest { @Nested class Validate { - @DisplayName("생년월일이 포함되지 않으면, 정상 통과한다.") @Test - void passes_whenNoBirthDate() { + void 생년월일이_포함되지_않으면_정상_통과한다() { assertDoesNotThrow(() -> PasswordPolicy.validate("Hx7!mK2@", BIRTH_DATE)); } - @DisplayName("생년월일(YYYYMMDD)이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsBirthDateYYYYMMDD() { + void 생년월일_YYYYMMDD가_포함되면_예외가_발생한다() { CoreException exception = assertThrows(CoreException.class, () -> { PasswordPolicy.validate("A19941115!", BIRTH_DATE); }); @@ -42,9 +43,8 @@ void throwsException_whenContainsBirthDateYYYYMMDD() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); } - @DisplayName("생년월일(YYMMDD)이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsBirthDateYYMMDD() { + void 생년월일_YYMMDD가_포함되면_예외가_발생한다() { CoreException exception = assertThrows(CoreException.class, () -> { PasswordPolicy.validate("A941115!a", BIRTH_DATE); }); @@ -52,9 +52,8 @@ void throwsException_whenContainsBirthDateYYMMDD() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); } - @DisplayName("생년월일(MMDD)이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsBirthDateMMDD() { + void 생년월일_MMDD가_포함되면_예외가_발생한다() { CoreException exception = assertThrows(CoreException.class, () -> { PasswordPolicy.validate("Abcd1115!", BIRTH_DATE); }); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 50908fac..7117a540 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,6 +24,7 @@ * * 교차 검증(생년월일 포함 금지)은 PasswordPolicy에서 별도 테스트 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class PasswordTest { @DisplayName("비밀번호를 생성할 때,") @@ -30,9 +33,8 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("모든 규칙을 만족하면, 정상적으로 생성된다.") @Test - void createsPassword_whenAllRulesSatisfied() { + void 모든_규칙을_만족하면_정상적으로_생성된다() { // arrange String rawPassword = "Hx7!mK2@"; @@ -43,9 +45,8 @@ void createsPassword_whenAllRulesSatisfied() { assertThat(password.getValue()).isEqualTo(rawPassword); } - @DisplayName("최소 길이(8자)이면, 정상적으로 생성된다.") @Test - void createsPassword_whenMinLength() { + void 최소_길이_8자이면_정상적으로_생성된다() { // arrange String rawPassword = "Xz5!qw9@"; // 8자 @@ -56,9 +57,8 @@ void createsPassword_whenMinLength() { assertThat(password.getValue()).isEqualTo(rawPassword); } - @DisplayName("최대 길이(16자)이면, 정상적으로 생성된다.") @Test - void createsPassword_whenMaxLength() { + void 최대_길이_16자이면_정상적으로_생성된다() { // arrange String rawPassword = "Px8!Kd3@Wm7#Rf2$"; // 16자 @@ -69,9 +69,8 @@ void createsPassword_whenMaxLength() { assertThat(password.getValue()).isEqualTo(rawPassword); } - @DisplayName("다양한 특수문자 조합이면, 정상적으로 생성된다.") @Test - void createsPassword_whenVariousSpecialChars() { + void 다양한_특수문자_조합이면_정상적으로_생성된다() { // arrange String rawPassword = "Ac1~`[]{}"; @@ -81,9 +80,8 @@ void createsPassword_whenVariousSpecialChars() { // ========== 엣지 케이스 ========== - @DisplayName("null이면, 예외가 발생한다.") @Test - void throwsException_whenNull() { + void null이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of(null); @@ -92,9 +90,8 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("빈 문자열이면, 예외가 발생한다.") @Test - void throwsException_whenEmpty() { + void 빈_문자열이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of(""); @@ -103,9 +100,8 @@ void throwsException_whenEmpty() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("7자(최소 미만)이면, 예외가 발생한다.") @Test - void throwsException_whenLessThanMinLength() { + void 7자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abcd12!"); // 7자 @@ -114,9 +110,8 @@ void throwsException_whenLessThanMinLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("17자(최대 초과)이면, 예외가 발생한다.") @Test - void throwsException_whenExceedsMaxLength() { + void 17자_최대_초과이면_예외가_발생한다() { // arrange String rawPassword = "Px8!Kd3@Wm7#Rf2$A"; // 17자 @@ -128,9 +123,8 @@ void throwsException_whenExceedsMaxLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("영문만 있으면(복잡도 미달), 예외가 발생한다.") @Test - void throwsException_whenOnlyLetters() { + void 영문만_있으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abcdefgh"); // 대문자+소문자 = 2종 @@ -139,9 +133,8 @@ void throwsException_whenOnlyLetters() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("숫자만 있으면(복잡도 미달), 예외가 발생한다.") @Test - void throwsException_whenOnlyDigits() { + void 숫자만_있으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("12345978"); // 숫자 = 1종 @@ -150,9 +143,8 @@ void throwsException_whenOnlyDigits() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("특수문자만 있으면(복잡도 미달), 예외가 발생한다.") @Test - void throwsException_whenOnlySpecialChars() { + void 특수문자만_있으면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("!@#$%^&*"); // 특수문자 = 1종 @@ -161,9 +153,8 @@ void throwsException_whenOnlySpecialChars() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("한글이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsKorean() { + void 한글이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abcd123가"); @@ -172,9 +163,8 @@ void throwsException_whenContainsKorean() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("공백이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpace() { + void 공백이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abcd 12!"); @@ -183,9 +173,8 @@ void throwsException_whenContainsSpace() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("동일 문자가 3회 이상 연속되면, 예외가 발생한다.") @Test - void throwsException_whenThreeConsecutiveSameChars() { + void 동일_문자가_3회_이상_연속되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Aaab123!@"); @@ -194,9 +183,8 @@ void throwsException_whenThreeConsecutiveSameChars() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("연속 숫자 3자리가 포함되면, 예외가 발생한다.") @Test - void throwsException_whenThreeSequentialDigits() { + void 연속_숫자_3자리가_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abzx1234!"); @@ -205,9 +193,8 @@ void throwsException_whenThreeSequentialDigits() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } - @DisplayName("연속 문자 3자리가 포함되면, 예외가 발생한다.") @Test - void throwsException_whenThreeSequentialChars() { + void 연속_문자_3자리가_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("xAbcz12!@"); @@ -216,4 +203,4 @@ void throwsException_whenThreeSequentialChars() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java index 1a07a8dc..8728f563 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,6 +22,7 @@ * 마스킹 규칙: * - 마지막 1글자를 '*'로 대체 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class UserNameTest { @DisplayName("이름을 생성할 때,") @@ -28,9 +31,8 @@ class Create { // ========== 정상 케이스 ========== - @DisplayName("한글 이름이면, 정상적으로 생성된다.") @Test - void createsUserName_whenKorean() { + void 한글_이름이면_정상적으로_생성된다() { // arrange String value = "홍길동"; @@ -41,9 +43,8 @@ void createsUserName_whenKorean() { assertThat(userName.getValue()).isEqualTo(value); } - @DisplayName("영문 이름이면, 정상적으로 생성된다.") @Test - void createsUserName_whenEnglish() { + void 영문_이름이면_정상적으로_생성된다() { // arrange String value = "Nahyeon"; @@ -54,9 +55,8 @@ void createsUserName_whenEnglish() { assertThat(userName.getValue()).isEqualTo(value); } - @DisplayName("최소 길이(2자)이면, 정상적으로 생성된다.") @Test - void createsUserName_whenMinLength() { + void 최소_길이_2자이면_정상적으로_생성된다() { // arrange String value = "홍길"; @@ -67,9 +67,8 @@ void createsUserName_whenMinLength() { assertThat(userName.getValue()).isEqualTo(value); } - @DisplayName("최대 길이(50자)이면, 정상적으로 생성된다.") @Test - void createsUserName_whenMaxLength() { + void 최대_길이_50자이면_정상적으로_생성된다() { // arrange String value = "가".repeat(50); @@ -80,9 +79,8 @@ void createsUserName_whenMaxLength() { assertThat(userName.getValue()).isEqualTo(value); } - @DisplayName("한글+영문 혼합이면, 정상적으로 생성된다.") @Test - void createsUserName_whenKoreanAndEnglishMixed() { + void 한글_영문_혼합이면_정상적으로_생성된다() { // arrange String value = "홍Nahyeon"; @@ -95,9 +93,8 @@ void createsUserName_whenKoreanAndEnglishMixed() { // ========== 엣지 케이스 ========== - @DisplayName("null이면, 예외가 발생한다.") @Test - void throwsException_whenNull() { + void null이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName(null); @@ -106,9 +103,8 @@ void throwsException_whenNull() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("빈 문자열이면, 예외가 발생한다.") @Test - void throwsException_whenEmpty() { + void 빈_문자열이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName(""); @@ -117,9 +113,8 @@ void throwsException_whenEmpty() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("1자(최소 미만)이면, 예외가 발생한다.") @Test - void throwsException_whenLessThanMinLength() { + void 1자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName("홍"); @@ -128,9 +123,8 @@ void throwsException_whenLessThanMinLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("51자(최대 초과)이면, 예외가 발생한다.") @Test - void throwsException_whenExceedsMaxLength() { + void 51자_최대_초과이면_예외가_발생한다() { // arrange String value = "가".repeat(51); @@ -142,9 +136,8 @@ void throwsException_whenExceedsMaxLength() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("숫자가 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsDigits() { + void 숫자가_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName("홍길동123"); @@ -153,9 +146,8 @@ void throwsException_whenContainsDigits() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("특수문자가 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpecialChars() { + void 특수문자가_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName("홍길동!"); @@ -164,9 +156,8 @@ void throwsException_whenContainsSpecialChars() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); } - @DisplayName("공백이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenContainsSpace() { + void 공백이_포함되면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName("홍 길동"); @@ -180,9 +171,8 @@ void throwsException_whenContainsSpace() { @Nested class Masking { - @DisplayName("3자 한글이면, 마지막 글자가 *로 대체된다.") @Test - void masksLastChar_whenThreeCharKorean() { + void 3자_한글이면_마지막_글자가_별표로_대체된다() { // arrange UserName userName = new UserName("홍길동"); @@ -193,9 +183,8 @@ void masksLastChar_whenThreeCharKorean() { assertThat(masked).isEqualTo("홍길*"); } - @DisplayName("2자 한글이면, 마지막 글자가 *로 대체된다.") @Test - void masksLastChar_whenTwoCharKorean() { + void 2자_한글이면_마지막_글자가_별표로_대체된다() { // arrange UserName userName = new UserName("홍길"); @@ -206,9 +195,8 @@ void masksLastChar_whenTwoCharKorean() { assertThat(masked).isEqualTo("홍*"); } - @DisplayName("영문이면, 마지막 글자가 *로 대체된다.") @Test - void masksLastChar_whenEnglish() { + void 영문이면_마지막_글자가_별표로_대체된다() { // arrange UserName userName = new UserName("Nahyeon"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java index 18bb6d5d..fcb8fc3e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -3,6 +3,8 @@ import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -28,6 +30,7 @@ * - 각 테스트가 독립적으로 실행될 수 있도록 보장 */ @SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class UserRepositoryIntegrationTest { @Autowired @@ -59,8 +62,7 @@ private User createTestUser(String loginIdValue) { class Save { @Test - @DisplayName("새로운 사용자를 저장하면, ID가 생성된다") - void createsIdWhenSavingNewUser() { + void 새로운_사용자를_저장하면_ID가_생성된다() { // arrange User user = createTestUser("testuser"); @@ -72,8 +74,7 @@ void createsIdWhenSavingNewUser() { } @Test - @DisplayName("저장된 사용자의 모든 필드가 올바르게 저장된다") - void savesAllFieldsCorrectly() { + void 저장된_사용자의_모든_필드가_올바르게_저장된다() { // arrange LoginId loginId = new LoginId("nahyeon"); String encodedPassword = "$2a$10$encodedPasswordHash"; @@ -102,8 +103,7 @@ void savesAllFieldsCorrectly() { class FindByLoginId { @Test - @DisplayName("존재하는 로그인ID로 조회하면, 해당 사용자를 반환한다") - void returnsUserWhenLoginIdExists() { + void 존재하는_로그인ID로_조회하면_해당_사용자를_반환한다() { // arrange User user = createTestUser("existinguser"); userRepository.save(user); @@ -119,8 +119,7 @@ void returnsUserWhenLoginIdExists() { } @Test - @DisplayName("존재하지 않는 로그인ID로 조회하면, empty를 반환한다") - void returnsEmptyWhenLoginIdNotExists() { + void 존재하지_않는_로그인ID로_조회하면_empty를_반환한다() { // arrange String nonExistingLoginId = "nonexisting"; @@ -137,8 +136,7 @@ void returnsEmptyWhenLoginIdNotExists() { class ExistsByLoginId { @Test - @DisplayName("존재하는 로그인ID면, true를 반환한다") - void returnsTrueWhenLoginIdExists() { + void 존재하는_로그인ID면_true를_반환한다() { // arrange User user = createTestUser("existinguser"); userRepository.save(user); @@ -151,8 +149,7 @@ void returnsTrueWhenLoginIdExists() { } @Test - @DisplayName("존재하지 않는 로그인ID면, false를 반환한다") - void returnsFalseWhenLoginIdNotExists() { + void 존재하지_않는_로그인ID면_false를_반환한다() { // arrange String nonExistingLoginId = "nonexisting"; @@ -163,4 +160,4 @@ void returnsFalseWhenLoginIdNotExists() { assertThat(exists).isFalse(); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index a78f7889..70921597 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -4,6 +4,8 @@ import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -20,6 +22,7 @@ /** * UserService 단위 테스트 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class UserServiceTest { private UserRepository userRepository; @@ -37,9 +40,8 @@ void setUp() { @Nested class Signup { - @DisplayName("유효한 정보면, 정상적으로 가입된다.") @Test - void signup_whenValidInput() { + void 유효한_정보면_정상적으로_가입된다() { // arrange when(userRepository.existsByLoginId(anyString())).thenReturn(false); when(passwordEncryptor.encode(anyString())).thenReturn("$2a$10$encodedHash"); @@ -58,9 +60,8 @@ void signup_whenValidInput() { ); } - @DisplayName("이미 존재하는 로그인 ID면, 예외가 발생한다.") @Test - void throwsException_whenDuplicateLoginId() { + void 이미_존재하는_로그인_ID면_예외가_발생한다() { // arrange when(userRepository.existsByLoginId(anyString())).thenReturn(true); @@ -72,9 +73,8 @@ void throwsException_whenDuplicateLoginId() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); } - @DisplayName("비밀번호에 생년월일이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenPasswordContainsBirthDate() { + void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { // arrange when(userRepository.existsByLoginId(anyString())).thenReturn(false); @@ -91,9 +91,8 @@ void throwsException_whenPasswordContainsBirthDate() { @Nested class Authenticate { - @DisplayName("유효한 ID/PW면, 사용자를 반환한다.") @Test - void returnsUser_whenValidCredentials() { + void 유효한_ID_PW면_사용자를_반환한다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", @@ -110,9 +109,8 @@ void returnsUser_whenValidCredentials() { assertThat(result.getLoginId().getValue()).isEqualTo("nahyeon"); } - @DisplayName("존재하지 않는 ID면, 예외가 발생한다.") @Test - void throwsException_whenUserNotFound() { + void 존재하지_않는_ID면_예외가_발생한다() { // arrange when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); @@ -124,9 +122,8 @@ void throwsException_whenUserNotFound() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); } - @DisplayName("비밀번호가 불일치하면, 예외가 발생한다.") @Test - void throwsException_whenPasswordMismatch() { + void 비밀번호가_불일치하면_예외가_발생한다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", @@ -149,9 +146,8 @@ void throwsException_whenPasswordMismatch() { @Nested class ChangePassword { - @DisplayName("유효한 요청이면, 비밀번호가 변경된다.") @Test - void changesPassword_whenValidRequest() { + void 유효한_요청이면_비밀번호가_변경된다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", @@ -169,9 +165,8 @@ void changesPassword_whenValidRequest() { assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); } - @DisplayName("현재 비밀번호가 틀리면, 예외가 발생한다.") @Test - void throwsException_whenCurrentPasswordWrong() { + void 현재_비밀번호가_틀리면_예외가_발생한다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", @@ -188,9 +183,8 @@ void throwsException_whenCurrentPasswordWrong() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); } - @DisplayName("새 비밀번호가 현재와 동일하면, 예외가 발생한다.") @Test - void throwsException_whenSameAsCurrentPassword() { + void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", @@ -207,9 +201,8 @@ void throwsException_whenSameAsCurrentPassword() { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); } - @DisplayName("새 비밀번호에 생년월일이 포함되면, 예외가 발생한다.") @Test - void throwsException_whenNewPasswordContainsBirthDate() { + void 새_비밀번호에_생년월일이_포함되면_예외가_발생한다() { // arrange - birthDate: 1990-03-25 (연속 동일 문자 없음) User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 1339f055..8a74db77 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -1,6 +1,8 @@ package com.loopers.domain.user; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -10,15 +12,15 @@ /** * User Entity 단위 테스트 */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class UserTest { @DisplayName("User를 생성할 때,") @Nested class Create { - @DisplayName("유효한 정보를 전달하면, 정상적으로 생성된다.") @Test - void createsUser_whenValidInput() { + void 유효한_정보를_전달하면_정상적으로_생성된다() { // arrange LoginId loginId = new LoginId("nahyeon"); String encodedPassword = "$2a$10$encodedPasswordHash"; @@ -44,9 +46,8 @@ void createsUser_whenValidInput() { @Nested class ChangePassword { - @DisplayName("새 인코딩된 비밀번호로 변경된다.") @Test - void changesPassword_whenNewEncodedPasswordGiven() { + void 새_인코딩된_비밀번호로_변경된다() { // arrange User user = User.create( new LoginId("nahyeon"), diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java index b8766bde..559fdb91 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -4,6 +4,8 @@ import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class AuthV1ApiE2ETest { private static final String SIGNUP_URL = "/api/v1/auth/signup"; @@ -53,9 +56,8 @@ private HttpHeaders authHeaders(String loginId, String password) { @Nested class Signup { - @DisplayName("유효한 정보로 가입하면, 201 Created 응답을 받는다.") @Test - void returns201_whenValidRequest() { + void 유효한_정보로_가입하면_201_Created_응답을_받는다() { // act ResponseEntity response = signup(validSignupRequest()); @@ -63,9 +65,8 @@ void returns201_whenValidRequest() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); } - @DisplayName("중복 로그인 ID로 가입하면, 409 Conflict 응답을 받는다.") @Test - void returns409_whenDuplicateLoginId() { + void 중복_로그인_ID로_가입하면_409_Conflict_응답을_받는다() { // arrange signup(validSignupRequest()); @@ -76,9 +77,8 @@ void returns409_whenDuplicateLoginId() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } - @DisplayName("잘못된 비밀번호 형식이면, 400 Bad Request 응답을 받는다.") @Test - void returns400_whenInvalidPassword() { + void 잘못된_비밀번호_형식이면_400_Bad_Request_응답을_받는다() { // arrange AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" @@ -91,9 +91,8 @@ void returns400_whenInvalidPassword() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } - @DisplayName("비밀번호에 생년월일이 포함되면, 400 Bad Request 응답을 받는다.") @Test - void returns400_whenPasswordContainsBirthDate() { + void 비밀번호에_생년월일이_포함되면_400_Bad_Request_응답을_받는다() { // arrange AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" @@ -113,9 +112,8 @@ void returns400_whenPasswordContainsBirthDate() { @Nested class ChangePassword { - @DisplayName("유효한 요청이면, 200 OK 응답을 받는다.") @Test - void returns200_whenValidRequest() { + void 유효한_요청이면_200_OK_응답을_받는다() { // arrange signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); @@ -130,9 +128,8 @@ void returns200_whenValidRequest() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } - @DisplayName("변경 후 새 비밀번호로 인증되고, 이전 비밀번호로는 실패한다.") @Test - void authenticatesWithNewPassword_afterChange() { + void 변경_후_새_비밀번호로_인증되고_이전_비밀번호로는_실패한다() { // arrange - 회원가입 + 비밀번호 변경 signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); @@ -157,9 +154,8 @@ void authenticatesWithNewPassword_afterChange() { ); } - @DisplayName("현재 비밀번호와 동일한 새 비밀번호면, 400 Bad Request 응답을 받는다.") @Test - void returns400_whenSamePassword() { + void 현재_비밀번호와_동일한_새_비밀번호면_400_Bad_Request_응답을_받는다() { // arrange signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index a453572c..33057c5a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -5,6 +5,8 @@ import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class UserV1ApiE2ETest { private static final String SIGNUP_URL = "/api/v1/auth/signup"; @@ -54,9 +57,8 @@ private HttpHeaders authHeaders(String loginId, String password) { @Nested class GetMyInfo { - @DisplayName("유효한 인증 정보로 조회하면, 200 OK + 마스킹된 이름을 반환한다.") @Test - void returns200WithMaskedName_whenValidAuth() { + void 유효한_인증_정보로_조회하면_200_OK와_마스킹된_이름을_반환한다() { // arrange signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); @@ -74,9 +76,8 @@ void returns200WithMaskedName_whenValidAuth() { ); } - @DisplayName("잘못된 비밀번호로 조회하면, 401 Unauthorized 응답을 받는다.") @Test - void returns401_whenWrongPassword() { + void 잘못된_비밀번호로_조회하면_401_Unauthorized_응답을_받는다() { // arrange signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!"); diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index eed159d2..8bbd1d20 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -1,14 +1,16 @@ package com.loopers.support.error; -import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class CoreExceptionTest { - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @Test - void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + void 별도_메시지가_없으면_ErrorType의_메시지를_사용한다() { // arrange CommonErrorType[] errorTypes = CommonErrorType.values(); @@ -19,9 +21,8 @@ void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { } } - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") @Test - void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + void 별도_메시지가_주어지면_해당_메시지를_사용한다() { // arrange String customMessage = "custom message"; From f937190dce25bb1cdec51ed00c3276b1869bd7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 16:39:01 +0900 Subject: [PATCH 25/44] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/auth/AuthFacade.java | 10 +++---- .../loopers/application/user/UserFacade.java | 4 +-- .../com/loopers/domain/user/UserService.java | 6 ++-- .../interfaces/api/auth/AuthV1ApiSpec.java | 4 +-- .../interfaces/api/auth/AuthV1Controller.java | 8 ++--- .../interfaces/api/user/UserV1ApiSpec.java | 2 +- .../interfaces/api/user/UserV1Controller.java | 4 +-- .../auth/AuthFacadeIntegrationTest.java | 30 +++++++++---------- .../user/UserFacadeIntegrationTest.java | 12 ++++---- .../loopers/domain/user/BirthDateTest.java | 2 +- .../com/loopers/domain/user/EmailTest.java | 2 +- .../com/loopers/domain/user/LoginIdTest.java | 4 +-- .../com/loopers/domain/user/PasswordTest.java | 4 +-- .../com/loopers/domain/user/UserNameTest.java | 8 ++--- .../loopers/domain/user/UserServiceTest.java | 20 ++++++------- 15 files changed, 60 insertions(+), 60 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java index 200e3d1e..c65fd69a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -13,13 +13,13 @@ public AuthFacade(UserService userService) { this.userService = userService; } - public UserInfo signup(String loginId, String password, String name, String birthDate, String email) { - User user = this.userService.signup(loginId, password, name, birthDate, email); + public UserInfo createUser(String loginId, String password, String name, String birthDate, String email) { + User user = this.userService.createUser(loginId, password, name, birthDate, email); return UserInfo.from(user); } - public void changePassword(String loginId, String headerPassword, String currentPassword, String newPassword) { - User user = this.userService.authenticate(loginId, headerPassword); - this.userService.changePassword(user, currentPassword, newPassword); + public void updateUserPassword(String loginId, String headerPassword, String currentPassword, String newPassword) { + User user = this.userService.authenticateUser(loginId, headerPassword); + this.userService.updateUserPassword(user, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 28a8a4db..5ae557cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -12,8 +12,8 @@ public UserFacade(UserService userService) { this.userService = userService; } - public UserInfo getMyInfo(String loginId, String password) { - User user = this.userService.authenticate(loginId, password); + public UserInfo getUser(String loginId, String password) { + User user = this.userService.authenticateUser(loginId, password); return UserInfo.from(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index c14e8ccb..287bb440 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -17,7 +17,7 @@ public UserService(UserRepository userRepository, PasswordEncryptor passwordEncr } @Transactional - public User signup(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { + public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { // 1. VO 생성 (각 VO가 자체 규칙 검증) LoginId loginId = new LoginId(rawLoginId); Password password = Password.of(rawPassword); @@ -40,7 +40,7 @@ public User signup(String rawLoginId, String rawPassword, String rawName, String } @Transactional(readOnly = true) - public User authenticate(String rawLoginId, String rawPassword) { + public User authenticateUser(String rawLoginId, String rawPassword) { User user = this.userRepository.findByLoginId(rawLoginId) .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); @@ -52,7 +52,7 @@ public User authenticate(String rawLoginId, String rawPassword) { } @Transactional - public void changePassword(User user, String currentRawPassword, String newRawPassword) { + public void updateUserPassword(User user, String currentRawPassword, String newRawPassword) { // 현재 비밀번호 확인 if (!this.passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.PASSWORD_MISMATCH); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java index 9ad7f5de..98dfeca9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java @@ -8,8 +8,8 @@ public interface AuthV1ApiSpec { @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") - ApiResponse signup(AuthV1Dto.SignupRequest request); + ApiResponse createUser(AuthV1Dto.SignupRequest request); @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") - ApiResponse changePassword(String loginId, String loginPw, AuthV1Dto.ChangePasswordRequest request); + ApiResponse updateUserPassword(String loginId, String loginPw, AuthV1Dto.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index 40d46f54..6adf064c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -19,8 +19,8 @@ public AuthV1Controller(AuthFacade authFacade) { @PostMapping("/signup") @ResponseStatus(HttpStatus.CREATED) @Override - public ApiResponse signup(@RequestBody AuthV1Dto.SignupRequest request) { - UserInfo info = this.authFacade.signup( + public ApiResponse createUser(@RequestBody AuthV1Dto.SignupRequest request) { + UserInfo info = this.authFacade.createUser( request.loginId(), request.password(), request.name(), request.birthDate(), request.email() ); return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); @@ -28,12 +28,12 @@ public ApiResponse signup(@RequestBody AuthV1Dto.Signu @PutMapping("/password") @Override - public ApiResponse changePassword( + public ApiResponse updateUserPassword( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw, @RequestBody AuthV1Dto.ChangePasswordRequest request ) { - this.authFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); + this.authFacade.updateUserPassword(loginId, loginPw, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 2fe92fa2..7f91f217 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -8,5 +8,5 @@ public interface UserV1ApiSpec { @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") - ApiResponse getMyInfo(String loginId, String loginPw); + ApiResponse getUser(String loginId, String loginPw); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index d9ac2ada..af6704a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -17,11 +17,11 @@ public UserV1Controller(UserFacade userFacade) { @GetMapping("/me") @Override - public ApiResponse getMyInfo( + public ApiResponse getUser( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - UserInfo info = this.userFacade.getMyInfo(loginId, loginPw); + UserInfo info = this.userFacade.getUser(loginId, loginPw); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java index 904c0526..cffad000 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -52,7 +52,7 @@ void tearDown() { } @Nested - @DisplayName("signup 메서드는") + @DisplayName("createUser 메서드는") class Signup { @Test @@ -65,7 +65,7 @@ class Signup { String email = "nahyeon@example.com"; // act - UserInfo result = authFacade.signup(loginId, password, name, birthDate, email); + UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email); // assert assertAll( @@ -87,7 +87,7 @@ class Signup { String email = "test@example.com"; // act - authFacade.signup(loginId, password, name, birthDate, email); + authFacade.createUser(loginId, password, name, birthDate, email); // assert assertThat(userRepository.existsByLoginId(loginId)).isTrue(); @@ -97,11 +97,11 @@ class Signup { void 중복된_로그인ID로_가입하면_예외가_발생한다() { // arrange String loginId = "duplicate"; - authFacade.signup(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); + authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.signup(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); + authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -118,7 +118,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.signup(loginId, password, name, birthDate, email); + authFacade.createUser(loginId, password, name, birthDate, email); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -126,7 +126,7 @@ class Signup { } @Nested - @DisplayName("changePassword 메서드는") + @DisplayName("updateUserPassword 메서드는") class ChangePassword { @Test @@ -135,10 +135,10 @@ class ChangePassword { String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; String newPassword = "Nw8@pL3#"; - authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); // act - authFacade.changePassword(loginId, currentPassword, currentPassword, newPassword); + authFacade.updateUserPassword(loginId, currentPassword, currentPassword, newPassword); // assert - 새 비밀번호로 인증 성공 확인 User user = userRepository.findByLoginId(loginId).orElseThrow(); @@ -150,11 +150,11 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.changePassword(loginId, "wrongPw1!", currentPassword, "Nw8@pL3#"); + authFacade.updateUserPassword(loginId, "wrongPw1!", currentPassword, "Nw8@pL3#"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); @@ -165,11 +165,11 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.changePassword(loginId, currentPassword, "wrongPw1!", "Nw8@pL3#"); + authFacade.updateUserPassword(loginId, currentPassword, "wrongPw1!", "Nw8@pL3#"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); @@ -180,11 +180,11 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.signup(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.changePassword(loginId, currentPassword, currentPassword, currentPassword); + authFacade.updateUserPassword(loginId, currentPassword, currentPassword, currentPassword); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java index 2f3e8271..b0c3efee 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java @@ -48,7 +48,7 @@ void tearDown() { } @Nested - @DisplayName("getMyInfo 메서드는") + @DisplayName("getUser 메서드는") class GetMyInfo { @Test @@ -56,10 +56,10 @@ class GetMyInfo { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; - authFacade.signup(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); // act - UserInfo result = userFacade.getMyInfo(loginId, password); + UserInfo result = userFacade.getUser(loginId, password); // assert assertAll( @@ -79,7 +79,7 @@ class GetMyInfo { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userFacade.getMyInfo(loginId, password); + userFacade.getUser(loginId, password); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); @@ -90,11 +90,11 @@ class GetMyInfo { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; - authFacade.signup(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userFacade.getMyInfo(loginId, "wrongPw1!"); + userFacade.getUser(loginId, "wrongPw1!"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index c6163b60..bd733e3a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -134,7 +134,7 @@ class Create { } @Test - void 1900년_이전이면_예외가_발생한다() { + void 날짜가_1900년_이전이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new BirthDate("1899-12-31"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 391d4f05..7c5ee1e4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -139,7 +139,7 @@ class Create { } @Test - void 256자를_초과하면_예외가_발생한다() { + void 길이가_256자를_초과하면_예외가_발생한다() { // arrange String value = "a".repeat(250) + "@b.com"; // 256자 diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index f6490bb4..5ecae448 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -97,7 +97,7 @@ class Create { } @Test - void 3자_최소_미만이면_예외가_발생한다() { + void 길이가_3자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new LoginId("abc"); @@ -107,7 +107,7 @@ class Create { } @Test - void 21자_최대_초과이면_예외가_발생한다() { + void 길이가_21자_최대_초과이면_예외가_발생한다() { // arrange String value = "abcdefghij12345678901"; // 21자 diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 7117a540..06ff225d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -101,7 +101,7 @@ class Create { } @Test - void 7자_최소_미만이면_예외가_발생한다() { + void 길이가_7자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { Password.of("Abcd12!"); // 7자 @@ -111,7 +111,7 @@ class Create { } @Test - void 17자_최대_초과이면_예외가_발생한다() { + void 길이가_17자_최대_초과이면_예외가_발생한다() { // arrange String rawPassword = "Px8!Kd3@Wm7#Rf2$A"; // 17자 diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java index 8728f563..370f5600 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -114,7 +114,7 @@ class Create { } @Test - void 1자_최소_미만이면_예외가_발생한다() { + void 길이가_1자_최소_미만이면_예외가_발생한다() { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { new UserName("홍"); @@ -124,7 +124,7 @@ class Create { } @Test - void 51자_최대_초과이면_예외가_발생한다() { + void 길이가_51자_최대_초과이면_예외가_발생한다() { // arrange String value = "가".repeat(51); @@ -172,7 +172,7 @@ class Create { class Masking { @Test - void 3자_한글이면_마지막_글자가_별표로_대체된다() { + void 이름이_3자_한글이면_마지막_글자가_별표로_대체된다() { // arrange UserName userName = new UserName("홍길동"); @@ -184,7 +184,7 @@ class Masking { } @Test - void 2자_한글이면_마지막_글자가_별표로_대체된다() { + void 이름이_2자_한글이면_마지막_글자가_별표로_대체된다() { // arrange UserName userName = new UserName("홍길"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 70921597..0a29cd36 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -48,7 +48,7 @@ class Signup { when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // act - User user = userService.signup("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); // assert assertAll( @@ -67,7 +67,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.signup("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -80,7 +80,7 @@ class Signup { // act & assert - birthDate: 1990-03-25, password contains "19900325" CoreException exception = assertThrows(CoreException.class, () -> { - userService.signup("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -103,7 +103,7 @@ class Authenticate { when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); // act - User result = userService.authenticate("nahyeon", "Hx7!mK2@"); + User result = userService.authenticateUser("nahyeon", "Hx7!mK2@"); // assert assertThat(result.getLoginId().getValue()).isEqualTo("nahyeon"); @@ -116,7 +116,7 @@ class Authenticate { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.authenticate("unknown", "Hx7!mK2@"); + userService.authenticateUser("unknown", "Hx7!mK2@"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); @@ -135,7 +135,7 @@ class Authenticate { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.authenticate("nahyeon", "wrongPw1!"); + userService.authenticateUser("nahyeon", "wrongPw1!"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); @@ -159,7 +159,7 @@ class ChangePassword { when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); // act - userService.changePassword(user, "Hx7!mK2@", "Nw8@pL3#"); + userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); // assert assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); @@ -177,7 +177,7 @@ class ChangePassword { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.changePassword(user, "wrongPw!", "Nw8@pL3#"); + userService.updateUserPassword(user, "wrongPw!", "Nw8@pL3#"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); @@ -195,7 +195,7 @@ class ChangePassword { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.changePassword(user, "Hx7!mK2@", "Hx7!mK2@"); + userService.updateUserPassword(user, "Hx7!mK2@", "Hx7!mK2@"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); @@ -213,7 +213,7 @@ class ChangePassword { // act & assert - newPassword contains "19900325" (birthDate) CoreException exception = assertThrows(CoreException.class, () -> { - userService.changePassword(user, "Hx7!mK2@", "X19900325!"); + userService.updateUserPassword(user, "Hx7!mK2@", "X19900325!"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); From 3b0ba2ea673ed41d46994999ee922655a8b3b8cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 16:45:31 +0900 Subject: [PATCH 26/44] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/auth/AuthFacade.java | 11 +++++++++++ .../java/com/loopers/application/user/UserFacade.java | 6 ++++++ .../java/com/loopers/application/user/UserInfo.java | 6 ++++++ .../src/main/java/com/loopers/domain/user/User.java | 7 +++++++ .../java/com/loopers/domain/user/UserRepository.java | 6 ++++++ .../java/com/loopers/domain/user/UserService.java | 6 ++++++ .../infrastructure/user/BCryptPasswordEncryptor.java | 5 +++++ .../infrastructure/user/UserRepositoryImpl.java | 5 +++++ .../loopers/interfaces/api/ApiControllerAdvice.java | 10 ++++++++++ .../java/com/loopers/interfaces/api/ApiResponse.java | 7 +++++++ .../loopers/interfaces/api/auth/AuthV1Controller.java | 6 ++++++ .../com/loopers/interfaces/api/auth/AuthV1Dto.java | 1 + .../loopers/interfaces/api/user/UserV1Controller.java | 5 +++++ .../com/loopers/interfaces/api/user/UserV1Dto.java | 2 ++ .../com/loopers/support/error/CommonErrorType.java | 1 + .../java/com/loopers/support/error/CoreException.java | 7 +++++++ .../java/com/loopers/support/error/ErrorType.java | 6 ++++++ .../java/com/loopers/support/error/UserErrorType.java | 9 +++++++++ 18 files changed, 106 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java index c65fd69a..e769b585 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -5,6 +5,12 @@ import com.loopers.domain.user.UserService; import org.springframework.stereotype.Component; +/** + * 인증 퍼사드 (Application Layer) + * + * 회원가입, 비밀번호 변경 등 인증 관련 유스케이스를 오케스트레이션한다. + * Entity를 외부에 노출하지 않고 UserInfo DTO로 변환하여 반환한다. + */ @Component public class AuthFacade { private final UserService userService; @@ -18,6 +24,11 @@ public UserInfo createUser(String loginId, String password, String name, String return UserInfo.from(user); } + /** + * 비밀번호 변경 시 이중 인증을 수행한다. + * 1단계: 헤더 credentials(loginId + headerPassword)로 사용자 인증 + * 2단계: 요청 본문의 currentPassword로 비밀번호 확인 후 변경 + */ public void updateUserPassword(String loginId, String headerPassword, String currentPassword, String newPassword) { User user = this.userService.authenticateUser(loginId, headerPassword); this.userService.updateUserPassword(user, currentPassword, newPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 5ae557cb..028f4c15 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -4,6 +4,12 @@ import com.loopers.domain.user.UserService; import org.springframework.stereotype.Component; +/** + * 사용자 퍼사드 (Application Layer) + * + * 사용자 정보 조회 유스케이스를 오케스트레이션한다. + * Entity를 외부에 노출하지 않고 UserInfo DTO로 변환하여 반환한다. + */ @Component public class UserFacade { private final UserService userService; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 43c95ca0..69f82ca5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -4,6 +4,12 @@ import java.time.LocalDate; +/** + * 사용자 정보 DTO (Application Layer) + * + * Entity를 외부 계층에 노출하지 않기 위한 변환용 DTO. + * maskedName은 개인정보 보호를 위해 이름의 마지막 글자를 마스킹한 값이다. + */ public record UserInfo( String loginId, String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index fd6c3fab..5ad97869 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -6,6 +6,12 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; +/** + * 사용자 엔티티 (Aggregate Root) + * + * 각 필드는 Value Object로 자체 검증을 수행하며, + * password는 암호화된 값만 저장한다 (평문 저장 금지). + */ @Entity @Table(name = "users") public class User extends BaseEntity { @@ -13,6 +19,7 @@ public class User extends BaseEntity { @Embedded private LoginId loginId; + /** 암호화된 비밀번호 (BCrypt 해시) */ @Column(name = "password", nullable = false) private String password; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 15889936..a27484ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -2,6 +2,12 @@ import java.util.Optional; +/** + * 사용자 리포지토리 포트 (Domain Layer) + * + * 도메인이 인프라(JPA)에 의존하지 않도록 추상화한 인터페이스. + * 실제 구현은 Infrastructure 계층의 UserRepositoryImpl이 담당한다. + */ public interface UserRepository { User save(User user); Optional findByLoginId(String loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 287bb440..44221793 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,6 +5,12 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +/** + * 사용자 도메인 서비스 + * + * 회원가입, 인증, 비밀번호 변경 등 사용자 도메인 핵심 비즈니스 로직을 담당한다. + * VO 자체 검증 → 교차 검증(PasswordPolicy) → 저장 순서로 처리한다. + */ @Component public class UserService { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java index d8f9daae..6645a0a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java @@ -4,6 +4,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; +/** + * PasswordEncryptor 어댑터 (Infrastructure Layer) + * + * Spring Security의 BCryptPasswordEncoder를 사용하여 비밀번호를 암호화한다. + */ @Component public class BCryptPasswordEncryptor implements PasswordEncryptor { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index bdb40ac0..ed1632e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -6,6 +6,11 @@ import java.util.Optional; +/** + * UserRepository 어댑터 (Infrastructure Layer) + * + * 도메인 포트(UserRepository)의 구현체로, Spring Data JPA에 위임한다. + */ @Component public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 7da9195d..9dd8822d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -22,10 +22,20 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +/** + * 전역 예외 핸들러 + * + * 에러 메시지 정책: + * - 클라이언트 응답: 사용자가 이해할 수 있는 메시지만 반환 (시스템 내부 정보 노출 금지) + * - 시스템 로그: 구체적인 요청 파라미터, 상태값, 스택트레이스를 기록 + * + * customMessage가 있으면 우선 사용하고, 없으면 ErrorType의 기본 메시지를 반환한다. + */ @RestControllerAdvice public class ApiControllerAdvice { private static final Logger log = LoggerFactory.getLogger(ApiControllerAdvice.class); + @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b52..68e7c49a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -1,5 +1,12 @@ package com.loopers.interfaces.api; +/** + * 표준 API 응답 래퍼 + * + * 모든 API 응답은 이 포맷으로 통일한다. + * - meta: 결과 상태(SUCCESS/FAIL), 에러 코드, 에러 메시지 + * - data: 응답 본문 (실패 시 null) + */ public record ApiResponse(Metadata meta, T data) { public record Metadata(Result result, String errorCode, String message) { public enum Result { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index 6adf064c..424b8739 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -6,6 +6,12 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +/** + * 인증 API 컨트롤러 (V1) + * + * 인증 방식: 커스텀 헤더(X-Loopers-LoginId, X-Loopers-LoginPw) 기반 인증. + * 비밀번호 변경 시 헤더 인증 + 본문 비밀번호 확인의 이중 검증을 수행한다. + */ @RestController @RequestMapping("/api/v1/auth") public class AuthV1Controller implements AuthV1ApiSpec { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java index 75c6310f..fb2af31d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java @@ -4,6 +4,7 @@ import java.time.LocalDate; +/** 인증 API V1 요청/응답 DTO */ public class AuthV1Dto { public record SignupRequest( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index af6704a5..558e28a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -5,6 +5,11 @@ import com.loopers.interfaces.api.ApiResponse; import org.springframework.web.bind.annotation.*; +/** + * 사용자 API 컨트롤러 (V1) + * + * 인증 방식: 커스텀 헤더(X-Loopers-LoginId, X-Loopers-LoginPw) 기반 인증. + */ @RestController @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 9af0b68a..e29e5e27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -4,8 +4,10 @@ import java.time.LocalDate; +/** 사용자 API V1 요청/응답 DTO */ public class UserV1Dto { + /** 이름은 개인정보 보호를 위해 maskedName으로 반환한다 */ public record UserResponse( String loginId, String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java index a5d64348..d1eba755 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -2,6 +2,7 @@ import org.springframework.http.HttpStatus; +/** 시스템 공통 에러 타입 (도메인에 속하지 않는 범용 에러) */ public enum CommonErrorType implements ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index dd373914..cf79738b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -1,5 +1,12 @@ package com.loopers.support.error; +/** + * 비즈니스 예외 + * + * customMessage가 있으면 클라이언트에 해당 메시지를 반환하고, + * 없으면 ErrorType의 기본 메시지를 반환한다. + * cause를 통해 원본 예외 체인을 보존하여 로그에서 추적 가능하게 한다. + */ public class CoreException extends RuntimeException { private final ErrorType errorType; private final String customMessage; 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 435f7098..c81a4046 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 @@ -2,6 +2,12 @@ import org.springframework.http.HttpStatus; +/** + * 에러 타입 인터페이스 + * + * 모든 에러 열거형(UserErrorType, CommonErrorType 등)이 구현하는 공통 계약. + * ApiControllerAdvice에서 HTTP 상태, 에러 코드, 메시지를 일관되게 추출한다. + */ public interface ErrorType { HttpStatus getStatus(); String getCode(); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java index b7e6e743..cce2a6fc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -2,6 +2,15 @@ import org.springframework.http.HttpStatus; +/** + * 사용자 도메인 에러 타입 + * + * 에러 코드 체계: USER_{카테고리}{순번} + * - 0xx: 입력값 검증 실패 (400) + * - 1xx: 인증 실패 (401) + * - 2xx: 리소스 미존재 (404) + * - 3xx: 충돌 (409) + */ public enum UserErrorType implements ErrorType { // 400 Bad Request INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "USER_001", "로그인 ID 형식이 올바르지 않습니다."), From e53300a7e1e88eb7b2518c2909d3aad51b7a95d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:15:54 +0900 Subject: [PATCH 27/44] =?UTF-8?q?fix:=20null=20=EC=84=A0=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?NPE=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20CRUD=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=8B=A8=EA=B3=84=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/user/PasswordPolicy.java | 6 ++ .../com/loopers/domain/user/UserService.java | 17 +++++ .../domain/user/PasswordPolicyTest.java | 27 ++++++++ .../loopers/domain/user/UserServiceTest.java | 66 +++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java index 9b169ffa..68de671d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java @@ -17,6 +17,12 @@ public final class PasswordPolicy { private PasswordPolicy() {} public static void validate(String rawPassword, LocalDate birthDate) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "비밀번호는 필수입니다."); + } + if (birthDate == null) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } validateBirthDateNotContained(rawPassword, birthDate); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 44221793..8679d659 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -47,6 +47,13 @@ public User createUser(String rawLoginId, String rawPassword, String rawName, St @Transactional(readOnly = true) public User authenticateUser(String rawLoginId, String rawPassword) { + if (rawLoginId == null || rawLoginId.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "로그인 ID는 필수입니다."); + } + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "비밀번호는 필수입니다."); + } + User user = this.userRepository.findByLoginId(rawLoginId) .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); @@ -59,6 +66,16 @@ public User authenticateUser(String rawLoginId, String rawPassword) { @Transactional public void updateUserPassword(User user, String currentRawPassword, String newRawPassword) { + if (user == null) { + throw new CoreException(UserErrorType.USER_NOT_FOUND, "사용자 정보가 존재하지 않습니다."); + } + if (currentRawPassword == null || currentRawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "현재 비밀번호는 필수입니다."); + } + if (newRawPassword == null || newRawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "새 비밀번호는 필수입니다."); + } + // 현재 비밀번호 확인 if (!this.passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.PASSWORD_MISMATCH); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java index 08760816..31482224 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java @@ -60,5 +60,32 @@ class Validate { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); } + + @Test + void 비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate(null, BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate(" ", BIRTH_DATE); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 생년월일이_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + PasswordPolicy.validate("Hx7!mK2@", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 0a29cd36..cb3ecf1b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -122,6 +122,33 @@ class Authenticate { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); } + @Test + void 로그인_ID가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser(null, "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 로그인_ID가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser(" ", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("nahyeon", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + @Test void 비밀번호가_불일치하면_예외가_발생한다() { // arrange @@ -165,6 +192,45 @@ class ChangePassword { assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); } + @Test + void 사용자가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(null, "Hx7!mK2@", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.USER_NOT_FOUND); + } + + @Test + void 현재_비밀번호가_null이면_예외가_발생한다() { + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, null, "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 새_비밀번호가_null이면_예외가_발생한다() { + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + @Test void 현재_비밀번호가_틀리면_예외가_발생한다() { // arrange From 36da4adf9591357577ed67d3b1f58ad978fc4ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:23:37 +0900 Subject: [PATCH 28/44] =?UTF-8?q?fix:=20=20BCryptPasswordEncryptor.java=20?= =?UTF-8?q?=E2=80=94=20null=20=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=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 --- .../user/BCryptPasswordEncryptor.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java index 6645a0a8..91ad2c5f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java @@ -1,6 +1,8 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.PasswordEncryptor; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; @@ -8,6 +10,8 @@ * PasswordEncryptor 어댑터 (Infrastructure Layer) * * Spring Security의 BCryptPasswordEncoder를 사용하여 비밀번호를 암호화한다. + * BCryptPasswordEncoder는 null 입력 시 IllegalArgumentException을 발생시키므로, + * 도메인 예외(CoreException)로 일관된 에러 응답을 보장하기 위해 null을 선검증한다. */ @Component public class BCryptPasswordEncryptor implements PasswordEncryptor { @@ -16,11 +20,20 @@ public class BCryptPasswordEncryptor implements PasswordEncryptor { @Override public String encode(String rawPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "암호화할 비밀번호는 필수입니다."); + } return this.encoder.encode(rawPassword); } @Override public boolean matches(String rawPassword, String encodedPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "비밀번호는 필수입니다."); + } + if (encodedPassword == null || encodedPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "암호화된 비밀번호가 존재하지 않습니다."); + } return this.encoder.matches(rawPassword, encodedPassword); } } \ No newline at end of file From e80650d4a041efa0c0c156e4e4b7872d492188b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:27:44 +0900 Subject: [PATCH 29/44] =?UTF-8?q?fix:=20loginIdValue=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20unique=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/LoginId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java index f41fe01c..725022df 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -22,7 +22,7 @@ public class LoginId { private static final int MAX_LENGTH = 20; private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9]{3,19}$"); - @Column(name = "login_id") + @Column(name = "login_id", unique = true, nullable = false) private String value; // JPA 기본 생성자 From 690afc9a1b445409e0074876c7e450097a48c10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:30:48 +0900 Subject: [PATCH 30/44] =?UTF-8?q?docs:=20claude.md=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 477 +++++++++++++++++++++++++++++++++++++++++++++- .claude/plan.md | 102 +++++++++- 2 files changed, 573 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0f01b32f..08ecbf43 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,7 @@ ## 프로젝트 개요 -Loopers에서 제공하는 Spring + Java 기반 멀티 모듈 템플릿 프로젝트입니다. 커머스 도메인을 위한 API, Batch, Streamer 애플리케이션을 포함합니다. +Spring + Java 기반 멀티 모듈 이커머스 프로젝트입니다. 상품, 주문, 회원, 결제 등 커머스 도메인을 직접 설계하고 구현하며, API, Batch, Streamer 애플리케이션으로 구성됩니다. ## 기술 스택 및 버전 @@ -117,4 +117,477 @@ docker-compose -f ./docker/monitoring-compose.yml up - **modules/supports**: 일반 Jar 활성화, BootJar 비활성화 ### 필수 연관 문서 -- plan.md 필수 참고 \ No newline at end of file +- plan.md 필수 참고 + +## 개발자 철학 (API 구현 규칙) + +아래 규칙은 이 프로젝트에서 API를 구현할 때 반드시 따라야 하는 원칙이다. + +### 1. 에러 처리: 검증 실패는 예외가 아닌 Response Return + +- 비즈니스 로직의 **검증 단계에서 발생하는 에러**는 `try/catch` 예외로 던지지 않고, **직접 Response를 return**한다. +- 예외(`throw`)는 **예상치 못한 시스템 에러**에만 사용한다. +- 검증 실패 시 클라이언트에게 **왜 실패했는지 명확한 메시지**를 포함한 응답을 반환한다. +- **의미 없는 `try/catch`는 지양한다.** 별도 처리 로직 없이 단순히 감싸기만 하는 `try/catch`는 작성하지 않는다. + +```java +// Good: 검증 실패 → Response return +if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); +} + +// Bad: 검증 실패를 예외로 던짐 +if (user == null) { + throw new CoreException(ErrorType.USER_NOT_FOUND); +} + +// Bad: 의미 없는 try/catch — 잡아서 다시 던지기만 하거나 아무 처리도 없음 +try { + userRepository.save(user); +} catch (Exception e) { + throw e; +} +``` + +### 2. 로깅 및 에러 메시지 분리 + +- **시스템 로그**: 예외 발생 시 `e`가 아닌 구체적인 정보(요청 파라미터, 상태값 등)를 로그에 남긴다. +- **클라이언트 응답**: 시스템 내부 에러 메시지를 노출하지 않고, 사용자가 이해할 수 있는 실패 사유 메시지를 반환한다. +- **예외 전파 시 메시지**: 예외를 감싸서 다시 던질 때 `e` 전체를 그대로 던지지 않고, **사용자가 이해할 수 있는 정확한 메시지**를 직접 작성하여 전달한다. 내부 스택트레이스나 시스템 정보가 그대로 노출되지 않도록 한다. + +```java +// Good: 구체적인 로그 + 사용자 친화적 응답 +log.error("사용자 조회 실패 - userId: {}, reason: {}", userId, e.getMessage(), e); +return ApiResponse.error("사용자 정보를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."); + +// Good: 예외 감싸서 던질 때 — 정확한 메시지 + cause 보존 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); + // 클라이언트에는 "YYYY-MM-DD 형식이어야 합니다" 전달 + // 시스템 로그에는 cause(DateTimeParseException)로 원인 추적 가능 +} + +// Bad: 모호한 로그 + 시스템 에러 노출 +log.error("error", e); +return ApiResponse.error(e.getMessage()); + +// Bad: e 전체를 그대로 메시지로 노출 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, e.toString()); + // "java.time.format.DateTimeParseException: Text '1994/11/15'..." 가 클라이언트에 노출됨 +} +``` + +### 3. 주석 규칙 + +- **정책/도메인 규칙**: 다른 개발자가 반드시 알아야 하는 비즈니스 정책은 상세한 주석을 작성한다. +- **일반 코드**: 간결한 주석만 작성한다. 코드 자체로 의도가 명확하면 주석을 생략한다. + +### 4. 테스트 코드 규칙 + +- 테스트 메서드명은 **한글**로 작성한다. +- 클래스에 `@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)`를 선언하여 언더스코어(`_`)가 공백으로 치환되도록 한다. + +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceTest { + + @Test + void 존재하지_않는_사용자_조회_시_에러_응답을_반환한다() { ... } + + @Test + void 비밀번호가_정책에_부합하지_않으면_에러_응답을_반환한다() { ... } +} +``` + +### 5. 함수명 및 URI 네이밍 규칙 + +- Controller, Service의 함수명은 **REST/도메인 관점**(`get`, `create`, `update`, `delete`)으로 작성한다. +- Repository는 Spring Data JPA 컨벤션(`find`, `save`, `delete`)을 따른다. +- 함수명에서 **단수와 복수를 혼합하지 않는다**. 일관되게 단수 또는 복수를 사용한다. +- URI와 함수명은 HTTP 메서드와 리소스 단/복수에 맞춰 아래 패턴을 따른다. + +| HTTP Method | URI 예시 | Controller/Service 함수명 | Repository 함수명 | +|-------------|----------|--------------------------|-------------------| +| GET | `/users` | `getUsers` | `findAll` | +| GET | `/users/{user_no}` | `getUser` | `findByUserNo` | +| POST | `/users` | `createUser` | `save` | +| PUT | `/users/{user_no}` | `updateUser` | `save` | +| DELETE | `/users/{user_no}` | `deleteUser` | `save` (소프트 삭제) | + +- **Path Variable 사용 기준**: 노출되어도 무방한 식별자(예: `user_no`)만 Path에 포함한다. 민감 정보(이메일, 주민번호 등)는 Path에 노출하지 않는다. + +### 6. 에러 코드 사용 기준 + +- **검증 단계 실패**: 에러 코드 없이, 직접 Response return으로 실패 사유를 명시한다. +- **예상치 못한 예외**: 에러 코드를 사용하여 시스템 로그에 추적 가능하도록 한다. + +### 7. 모든 CRUD 작업에 검증 단계 필수 + +- **조회, 등록, 수정, 삭제** 모든 작업에서 데이터 검증 단계를 반드시 거친다. +- 클라이언트로부터 과도한 정보를 받아 그대로 처리하지 않는다. **검증된 데이터만으로** 로직을 수행한다. +- 잘못된 데이터가 조회되거나 존재하지 않는 데이터에 대한 요청이 들어오면, 즉시 실패 Response를 return한다. + +```java +// Good: 검증 후 처리 +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { + User user = userRepository.findById(userNo).orElse(null); + if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + if (user.isDeleted()) { + return ApiResponse.error("이미 탈퇴한 사용자입니다."); + } + // 검증 통과 후 업데이트 수행 + user.updateInfo(request.getNickname()); + return ApiResponse.success(user); +} + +// Bad: 검증 없이 바로 처리 +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { + userRepository.updateByUserNo(userNo, request.getNickname()); + return ApiResponse.success(); +} +``` + +### 8. 삭제는 소프트 삭제(Soft Delete)를 지향한다 + +- 데이터를 물리적으로 삭제(`DELETE FROM`)하지 않고, **`deleted_at` 컬럼을 업데이트**하여 논리 삭제를 수행한다. +- 삭제 API의 내부 구현은 실질적으로 **UPDATE** 처리이다. +- 조회 시 `deleted_at IS NULL` 조건으로 삭제되지 않은 데이터만 필터링한다. + +```java +// Good: 소프트 삭제 — deleted_at 업데이트 +public ApiResponse deleteUser(Long userNo) { + User user = userRepository.findById(userNo).orElse(null); + if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + if (user.isDeleted()) { + return ApiResponse.error("이미 탈퇴한 사용자입니다."); + } + user.delete(); // deleted_at = LocalDateTime.now() + return ApiResponse.success(); +} + +// Bad: 물리 삭제 +public ApiResponse deleteUser(Long userNo) { + userRepository.deleteById(userNo); + return ApiResponse.success(); +} +``` + +## RESTful API 컨벤션 + +### 1. URI는 동사가 아닌 명사를 사용한다 + +- 리소스를 나타내는 URI에는 행위(동사)가 아닌 **명사**를 사용한다. +- 행위는 HTTP Method(GET, POST, PUT, DELETE)로 표현한다. + +``` +# Good +GET /users +POST /users +PUT /users/{user_no} +DELETE /users/{user_no} + +# Bad +GET /getUsers +POST /createUser +PUT /updateUser +DELETE /removeUser +``` + +### 2. 긴 URI에는 하이픈(`-`)을 사용한다 + +- URI가 길어질 경우 단어 구분자로 **하이픈(`-`)**을 사용한다. +- 언더스코어(`_`)나 camelCase는 사용하지 않는다. + +``` +# Good +GET /user-addresses +GET /order-histories + +# Bad +GET /user_addresses +GET /orderHistories +``` + +### 3. 필터링은 기존 GET API에 쿼리 파라미터로 추가한다 + +- 필터링 조건이 필요할 때 **새로운 API를 만들지 않고**, 기존 GET API에 **URL 쿼리 파라미터**를 붙여서 처리한다. + +``` +# Good: 기존 API에 쿼리 파라미터로 필터링 +GET /users?status=active +GET /users?status=active&role=admin +GET /orders?start-date=2026-01-01&end-date=2026-01-31 + +# Bad: 필터링 조건별로 새 API 생성 +GET /active-users +GET /admin-users +GET /orders-by-date +``` + +## 코드 품질 컨벤션 + +### 1. DTO 분리 (Request/Response) + +- Controller에서 **Entity를 직접 노출하지 않는다**. 요청/응답 전용 DTO를 사용한다. +- 내부 Entity 구조가 변경되더라도 API 스펙에 영향을 주지 않도록 분리한다. + +```java +// Good: 전용 DTO 사용 +@PostMapping("/users") +public ApiResponse insertUsers(@RequestBody InsertUserRequest request) { + ... + return ApiResponse.success(InsertUserResponse.from(user)); +} + +// Bad: Entity 직접 노출 +@PostMapping("/users") +public ApiResponse insertUsers(@RequestBody User user) { + ... + return ApiResponse.success(userRepository.save(user)); +} +``` + +### 2. 계층 간 의존성 규칙 + +- **`Controller → Service → Repository`** 단방향만 허용한다. +- Service가 Controller를 참조하거나, Repository가 Service를 참조하는 **역방향 의존은 금지**한다. + +``` +# Good +Controller → Service → Repository + +# Bad +Controller ← Service (역방향) +Repository → Service (역방향) +Service → Controller (역방향) +``` + +### 3. 트랜잭션 관리 + +- **조회 메서드**에는 `@Transactional(readOnly = true)`를 명시하여 불필요한 쓰기 잠금을 방지한다. +- **변경 작업**(등록, 수정, 삭제)에만 `@Transactional`을 사용한다. + +```java +// Good: 조회는 readOnly +@Transactional(readOnly = true) +public ApiResponse selectUsers() { ... } + +// Good: 변경은 @Transactional +@Transactional +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { ... } +``` + +### 4. 매직 넘버/문자열 금지 + +- 코드 내 의미가 불명확한 숫자나 문자열 리터럴을 직접 사용하지 않는다. +- **`상수(static final)`** 또는 **`enum`**으로 관리한다. + +```java +// Good: 상수로 관리 +private static final int MAX_LOGIN_ATTEMPTS = 5; +if (loginAttempts >= MAX_LOGIN_ATTEMPTS) { ... } + +// Bad: 매직 넘버 +if (loginAttempts >= 5) { ... } +``` + +### 5. 메서드 단일 책임 + +- 하나의 메서드는 **하나의 역할만** 수행한다. +- 메서드가 길어지면 의미 단위로 **private 메서드로 분리**한다. + +```java +// Good: 역할별 분리 +public ApiResponse insertUsers(InsertUserRequest request) { + ApiResponse validationResult = validateInsertRequest(request); + if (validationResult != null) { + return validationResult; + } + User user = createUser(request); + return ApiResponse.success(InsertUserResponse.from(user)); +} + +private ApiResponse validateInsertRequest(InsertUserRequest request) { ... } +private User createUser(InsertUserRequest request) { ... } +``` + +### 6. Null 안전성 + +- `null`을 직접 반환하거나 비교하기보다 **`Optional`을 적극 활용**한다. +- 단, **Entity 필드에는 `Optional`을 사용하지 않고**, 조회 반환 시에만 사용한다. + +```java +// Good: Optional 활용 +Optional userOpt = userRepository.findByUserNo(userNo); +if (userOpt.isEmpty()) { + return ApiResponse.error("존재하지 않는 사용자입니다."); +} +User user = userOpt.get(); + +// Bad: null 직접 비교 +User user = userRepository.findByUserNo(userNo); +if (user == null) { ... } +``` + +### 7. 순환 참조 금지 + +- Service 간, 또는 계층 간 **순환 참조(Circular Dependency)를 절대 허용하지 않는다**. +- 순환이 발생할 경우 공통 로직을 별도 Service로 분리하거나, 이벤트 기반으로 의존을 끊는다. + +``` +# Bad: 순환 참조 +UserService → OrderService → UserService + +# Good: 공통 로직 분리 +UserService → UserOrderFacade ← OrderService +``` + +### 8. Lombok 사용 금지 + +- **Lombok을 사용하지 않는다.** 생성자, getter 등을 직접 작성한다. +- IDE의 코드 생성 기능이나 `record`를 활용하여 보일러플레이트를 줄인다. + +```java +// Good: 직접 작성 +public class User { + private final String name; + + public User(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } +} + +// Bad: Lombok 사용 +@Getter +@AllArgsConstructor +public class User { + private String name; +} +``` + +### 9. 의존성 주입은 생성자 + `this` 접두어로 불변성 보장 + +- 의존성 주입은 **생성자 주입**을 사용하고, 필드는 `private final`로 선언한다. +- 호출 시 반드시 **`this.`** 접두어를 붙여 인스턴스 필드임을 명확히 한다. + +```java +// Good: 생성자 주입 + this 사용 +public class UserService { + private final UserRepository userRepository; + private final UserMapper userMapper; + + public UserService(UserRepository userRepository, UserMapper userMapper) { + this.userRepository = userRepository; + this.userMapper = userMapper; + } + + public ApiResponse selectUsers() { + List users = this.userRepository.findAll(); + return ApiResponse.success(this.userMapper.toResponseList(users)); + } +} + +// Bad: @Autowired 필드 주입 + this 미사용 +@Service +public class UserService { + @Autowired + private UserRepository userRepository; + + public ApiResponse selectUsers() { + return ApiResponse.success(userRepository.findAll()); + } +} +``` + +### 10. DTO는 `record`를 사용한다 + +- Request/Response DTO는 Java `record`로 선언하여 **불변성을 보장**한다. +- `record`는 생성자, getter, `equals`, `hashCode`, `toString`을 자동 제공한다. + +```java +// Good: record 사용 +public record InsertUserRequest( + String email, + String password, + String nickname +) {} + +public record InsertUserResponse( + Long userNo, + String email, + String nickname +) { + public static InsertUserResponse from(User user) { + return new InsertUserResponse(user.getUserNo(), user.getEmail(), user.getNickname()); + } +} + +// Bad: class + getter +public class InsertUserRequest { + private String email; + private String password; + private String nickname; + // getter/setter ... +} +``` + +### 11. Entity 외부 노출 금지 + +- **Entity를 Controller 응답으로 직접 반환하지 않는다.** +- 반드시 Response DTO로 변환하여 반환한다. 이는 내부 테이블 구조 및 민감 필드 노출을 방지한다. + +### 12. Null 체크 검증 로직 필수 + +- 모든 조회 결과, 외부 입력값, 파라미터에 대해 **null 체크를 필수로 수행**한다. +- null일 경우 즉시 실패 Response를 return한다. null 상태로 로직을 계속 진행하지 않는다. + +```java +// Good: null 체크 후 즉시 return +public ApiResponse selectUser(Long userNo) { + if (userNo == null) { + return ApiResponse.error("사용자 번호는 필수입니다."); + } + Optional userOpt = this.userRepository.findByUserNo(userNo); + if (userOpt.isEmpty()) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + return ApiResponse.success(SelectUserResponse.from(userOpt.get())); +} + +// Bad: null 체크 없이 진행 +public ApiResponse selectUser(Long userNo) { + User user = this.userRepository.findByUserNo(userNo).get(); // NoSuchElementException 위험 + return ApiResponse.success(user); // Entity 직접 노출 +} +``` + +## 소프트웨어 설계 원칙 + +### 1. 기술 도입은 문제 정의가 먼저다 + +- 새로운 라이브러리나 기술을 도입할 때, **해결하려는 문제를 먼저 명확히 정의**한다. +- 유행이나 편의가 아닌, 성능·복원력·유지보수성을 기준으로 가장 단순하고 강건한 방법을 선택한다. + +### 2. 설계의도를 고려한다. + +- 서비스 경계를 명확히 하고, 상태 동기화 흐름을 문서화한다. + +### 3. 개선과 실험은 반드시 테스트로 검증한다 + +- 성능 개선, 구조 변경 등은 **테스트 코드로 검증 가능한 상태**에서 진행한다. +- 추측이 아닌, 재현 가능한 테스트와 측정 데이터를 근거로 의사결정한다. + +### 4. 설계 의도는 코드와 문서에 함께 남긴다 + +- 비즈니스 플로우, API 계약, 서비스 간 데이터 흐름 등 **설계 의도를 문서와 주석으로 명시**한다. +- 코드만으로 파악하기 어려운 도메인 맥락은 반드시 기록한다. \ No newline at end of file diff --git a/.claude/plan.md b/.claude/plan.md index 93c37715..073b3aed 100644 --- a/.claude/plan.md +++ b/.claude/plan.md @@ -46,12 +46,24 @@ AI는 개발자의 역량을 증강하는 도구이며, 의사결정의 주체 3. 테스트 가능한 구조 설계 4. 기존 코드 패턴과 일관성 유지 -## 4. Git 컨벤션 +## 4. 오류 수정 컨벤션 -- **커밋 주체**: 개발자가 직접 수행 (AI 임의 커밋 금지) +오류를 수정할 때는 반드시 아래 형식으로 **오류 수정 이력** 섹션에 기록한다. + +| 항목 | 설명 | +|------|------| +| **AS-IS** | 수정 전 코드 | +| **TO-BE** | 수정 후 코드 | +| **왜 (Why)** | 왜 이 수정이 필요한지 | +| **동작 원리** | 내부적으로 어떻게 동작하는지, 이유 | +| **검증 테스트** | 수정이 올바른지 확인하는 테스트 | + +## 5. Git 컨벤션 + +- **커밋 주체**: 개발자진행 방향가 직접 수행 (AI 임의 커밋 금지) - **커밋 메시지**: Conventional Commits 형식 권장 -## 5. AI 협업 스타일 +## 6. AI 협업 스타일 본 프로젝트에서 AI와의 협업은 다음 방식을 지향합니다: @@ -61,4 +73,86 @@ AI는 개발자의 역량을 증강하는 도구이며, 의사결정의 주체 | **Explanation-seeking** | 코드의 이유, 원리, 동작에 대한 설명 요구 | | **Iterative-reasoning** | 문제를 분해하여 추론 → 질문 → 수정 반복 | -> AI는 답을 제공하는 것이 아닌, 사고를 돕는 도구로 활용합니다. \ No newline at end of file +> AI는 답을 제공하는 것이 아닌, 사고를 돕는 도구로 활용합니다. + +--- + +## 오류 수정 이력 + +오류 수정 시 AS-IS / TO-BE / 왜(Why) / 동작 원리를 반드시 기록한다. + +--- + +### [#1] CoreException 예외 원인(cause) 유실 문제 + +**출처**: CodeRabbit 리뷰 + +#### AS-IS + +```java +// CoreException — cause를 받는 생성자 없음 +public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; +} + +// BirthDate.parseDate — 원래 예외(e)를 전달하지 않음 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다."); +} +``` + +#### TO-BE + +```java +// CoreException — cause를 받는 3-파라미터 생성자 추가 +public CoreException(ErrorType errorType, String customMessage, Throwable cause) { + super(customMessage != null ? customMessage : errorType.getMessage(), cause); + this.errorType = errorType; + this.customMessage = customMessage; +} + +// BirthDate.parseDate — 원래 예외(e)를 cause로 전달 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); +} +``` + +#### 왜 (Why) + +`catch`에서 새로운 예외를 던질 때 원래 예외(`e`)를 넘기지 않으면 **예외 체인(Exception Chain)이 끊긴다.** +운영 환경에서 장애가 발생했을 때 로그에 `Caused by`가 남지 않아, 정확히 어떤 입력값이 왜 실패했는지 추적할 수 없다. + +#### 동작 원리 + +Java의 모든 예외는 `Throwable.cause` 필드를 가진다. `super(message, cause)`로 원인을 연결하면 예외 체인이 형성된다. + +``` +// cause 없는 경우 — 원인 추적 불가 +CoreException: 생년월일은 YYYY-MM-DD 형식이어야 합니다. + at BirthDate.parseDate(BirthDate.java:52) + +// cause 있는 경우 — 원인 추적 가능 +CoreException: 생년월일은 YYYY-MM-DD 형식이어야 합니다. + at BirthDate.parseDate(BirthDate.java:52) +Caused by: DateTimeParseException: Text '1994/11/15' could not be parsed + at java.time.format.DateTimeFormatter.parseResolved0(...) +``` + +`Caused by`가 있어야 "어떤 값이, 어떤 이유로 파싱에 실패했는지" 정확히 파악할 수 있다. 이는 운영 환경에서 장애 원인 파악 시간을 줄이는 데 직결된다. + +#### 검증 테스트 + +```java +@DisplayName("잘못된 형식이면, 예외의 원인으로 DateTimeParseException을 포함한다.") +@Test +void preservesCauseWhenInvalidFormat() { + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + assertThat(exception.getCause()).isInstanceOf(DateTimeParseException.class); +} +``` \ No newline at end of file From 1ca4c569d50cc7c11fe718091c2c30000f4fdf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:37:12 +0900 Subject: [PATCH 31/44] =?UTF-8?q?fix:=20AuthV1Dto.java=20=E2=80=94=20toStr?= =?UTF-8?q?ing()=20=EC=9E=AC=EC=A0=95=EC=9D=98=EB=A1=9C=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=A7=88=EC=8A=A4=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/auth/AuthV1Dto.java | 17 ++- .../user/BCryptPasswordEncryptorTest.java | 121 ++++++++++++++++++ .../interfaces/api/AuthV1ApiE2ETest.java | 2 +- 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java index fb2af31d..e684e71b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java @@ -13,7 +13,14 @@ public record SignupRequest( String name, String birthDate, String email - ) {} + ) { + /** 비밀번호 평문 노출 방지 */ + @Override + public String toString() { + return "SignupRequest[loginId=" + loginId + ", password=*****, name=" + name + + ", birthDate=" + birthDate + ", email=" + email + "]"; + } + } public record SignupResponse( String loginId, @@ -29,5 +36,11 @@ public static SignupResponse from(UserInfo info) { public record ChangePasswordRequest( String currentPassword, String newPassword - ) {} + ) { + /** 비밀번호 평문 노출 방지 */ + @Override + public String toString() { + return "ChangePasswordRequest[currentPassword=*****, newPassword=*****]"; + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java new file mode 100644 index 00000000..88aba505 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * BCryptPasswordEncryptor 단위 테스트 + * + * BCryptPasswordEncoder가 null 입력 시 IllegalArgumentException을 발생시키므로, + * 어댑터 계층에서 CoreException으로 일관 처리하는지 검증한다. + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class BCryptPasswordEncryptorTest { + + private BCryptPasswordEncryptor encryptor; + + @BeforeEach + void setUp() { + encryptor = new BCryptPasswordEncryptor(); + } + + @DisplayName("비밀번호 암호화 시,") + @Nested + class Encode { + + @Test + void 유효한_비밀번호면_암호화된_문자열을_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encoded).isNotBlank(); + assertThat(encoded).startsWith("$2a$"); + } + + @Test + void 비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.encode(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.encode(" "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + } + + @DisplayName("비밀번호 일치 검증 시,") + @Nested + class Matches { + + @Test + void 일치하는_비밀번호면_true를_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encryptor.matches("Hx7!mK2@", encoded)).isTrue(); + } + + @Test + void 불일치하는_비밀번호면_false를_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encryptor.matches("wrongPw1!", encoded)).isFalse(); + } + + @Test + void 평문_비밀번호가_null이면_예외가_발생한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches(null, encoded); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 평문_비밀번호가_빈_문자열이면_예외가_발생한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches(" ", encoded); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 암호화된_비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches("Hx7!mK2@", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 암호화된_비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches("Hx7!mK2@", " "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java index 559fdb91..a4ca16da 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -108,7 +108,7 @@ class Signup { // ========== 비밀번호 변경 ========== - @DisplayName("PATCH /api/v1/auth/password") + @DisplayName("PUT /api/v1/auth/password") @Nested class ChangePassword { From 4479253bea4d35218ddd654f54389b2568cf1f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:49:07 +0900 Subject: [PATCH 32/44] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserService.java | 11 ++++-- .../auth/AuthFacadeIntegrationTest.java | 23 ++++++++++++ .../loopers/domain/user/UserServiceTest.java | 35 +++++++++++++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8679d659..4aaaddc5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.springframework.stereotype.Component; @@ -93,7 +94,13 @@ public void updateUserPassword(User user, String currentRawPassword, String newR } // 암호화 후 변경 및 저장 (detached 엔티티 대응) - user.changePassword(this.passwordEncryptor.encode(newRawPassword)); - this.userRepository.save(user); + String newEncodedPassword = this.passwordEncryptor.encode(newRawPassword); + user.changePassword(newEncodedPassword); + User savedUser = this.userRepository.save(user); + + // 영속화 검증: 저장된 엔티티의 비밀번호가 정상 반영되었는지 확인 + if (!savedUser.getPassword().equals(newEncodedPassword)) { + throw new CoreException(CommonErrorType.INTERNAL_ERROR, "비밀번호 변경이 정상적으로 반영되지 않았습니다."); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java index cffad000..994b2994 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; @@ -43,6 +44,9 @@ class AuthFacadeIntegrationTest { @Autowired private PasswordEncryptor passwordEncryptor; + @Autowired + private EntityManager entityManager; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -145,6 +149,25 @@ class ChangePassword { assertThat(passwordEncryptor.matches(newPassword, user.getPassword())).isTrue(); } + @Test + void 변경된_비밀번호로_재인증에_성공하고_이전_비밀번호는_실패한다() { + // arrange + String loginId = "nahyeon"; + String oldPassword = "Hx7!mK2@"; + String newPassword = "Nw8@pL3#"; + authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + + // act - 비밀번호 변경 (authenticateUser → detached entity → updateUserPassword에서 save) + authFacade.updateUserPassword(loginId, oldPassword, oldPassword, newPassword); + + // assert - DB에서 재조회하여 새 비밀번호로 인증 성공 확인 + User reloadedUser = userRepository.findByLoginId(loginId).orElseThrow(); + assertAll( + () -> assertThat(passwordEncryptor.matches(newPassword, reloadedUser.getPassword())).isTrue(), + () -> assertThat(passwordEncryptor.matches(oldPassword, reloadedUser.getPassword())).isFalse() + ); + } + @Test void 헤더_비밀번호가_틀리면_인증_예외가_발생한다() { // arrange diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index cb3ecf1b..ad6664ff 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -12,11 +12,14 @@ import java.util.Optional; +import com.loopers.support.error.CommonErrorType; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -174,7 +177,7 @@ class Authenticate { class ChangePassword { @Test - void 유효한_요청이면_비밀번호가_변경된다() { + void 유효한_요청이면_비밀번호가_변경되고_영속화된다() { // arrange User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", @@ -184,14 +187,42 @@ class ChangePassword { when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // act userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); - // assert + // assert - 영속화 호출 검증 + 변경된 비밀번호 검증 + verify(userRepository).save(user); assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); } + @Test + void 저장_후_비밀번호가_반영되지_않으면_시스템_예외가_발생한다() { + // arrange - save()가 변경 전 비밀번호를 가진 엔티티를 반환하는 비정상 시나리오 + User user = User.create( + new LoginId("nahyeon"), "$2a$10$oldHash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + User staleUser = User.create( + new LoginId("nahyeon"), "$2a$10$oldHash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); + when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); + when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); + when(userRepository.save(any(User.class))).thenReturn(staleUser); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(CommonErrorType.INTERNAL_ERROR); + } + @Test void 사용자가_null이면_예외가_발생한다() { CoreException exception = assertThrows(CoreException.class, () -> { From 5917066ea80ea460fb6bcd9d80db156db08c5ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 17:56:19 +0900 Subject: [PATCH 33/44] =?UTF-8?q?fix:=20=EB=AF=BC=EA=B0=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ http/http-client.env.json | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5a979af6..6065508e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### HTTP Client ### +http-client.private.env.json diff --git a/http/http-client.env.json b/http/http-client.env.json index 6d7eb143..a3ae2295 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -1,7 +1,5 @@ { "local": { - "commerce-api": "http://localhost:8080", - "loginId": "testuser01", - "loginPw": "Test10395!@" + "commerce-api": "http://localhost:8080" } } \ No newline at end of file From 6bf19b09926ff8081adefcc3170b9f3bc6baea7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 18:40:13 +0900 Subject: [PATCH 34/44] =?UTF-8?q?refactor:=20vo=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EB=B6=84=EB=A6=AC=20,=20Gender=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 --- .../loopers/application/auth/AuthFacade.java | 5 ++- .../loopers/application/user/UserInfo.java | 7 +++- .../java/com/loopers/domain/user/Gender.java | 5 +++ .../java/com/loopers/domain/user/User.java | 26 ++++++++++-- .../com/loopers/domain/user/UserService.java | 10 ++++- .../user/{ => policy}/PasswordPolicy.java | 26 +++++++----- .../domain/user/{ => vo}/BirthDate.java | 4 +- .../loopers/domain/user/{ => vo}/Email.java | 4 +- .../loopers/domain/user/{ => vo}/LoginId.java | 2 +- .../domain/user/{ => vo}/Password.java | 4 +- .../domain/user/{ => vo}/UserName.java | 4 +- .../interfaces/api/auth/AuthV1Controller.java | 2 +- .../interfaces/api/auth/AuthV1Dto.java | 11 +++-- .../interfaces/api/user/UserV1Dto.java | 6 ++- .../loopers/support/error/UserErrorType.java | 1 + .../auth/AuthFacadeIntegrationTest.java | 28 +++++++------ .../user/UserFacadeIntegrationTest.java | 5 ++- .../loopers/domain/user/BirthDateTest.java | 1 + .../com/loopers/domain/user/EmailTest.java | 1 + .../com/loopers/domain/user/LoginIdTest.java | 1 + .../domain/user/PasswordPolicyTest.java | 1 + .../com/loopers/domain/user/PasswordTest.java | 1 + .../com/loopers/domain/user/UserNameTest.java | 1 + .../user/UserRepositoryIntegrationTest.java | 9 ++++- .../loopers/domain/user/UserServiceTest.java | 40 +++++++++++++------ .../com/loopers/domain/user/UserTest.java | 32 +++++++++++++-- .../interfaces/api/AuthV1ApiE2ETest.java | 7 ++-- .../interfaces/api/UserV1ApiE2ETest.java | 3 +- 28 files changed, 177 insertions(+), 70 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => policy}/PasswordPolicy.java (59%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => vo}/BirthDate.java (98%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => vo}/Email.java (98%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => vo}/LoginId.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => vo}/Password.java (99%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{ => vo}/UserName.java (97%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java index e769b585..d6d9b5f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -1,6 +1,7 @@ package com.loopers.application.auth; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.Gender; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import org.springframework.stereotype.Component; @@ -19,8 +20,8 @@ public AuthFacade(UserService userService) { this.userService = userService; } - public UserInfo createUser(String loginId, String password, String name, String birthDate, String email) { - User user = this.userService.createUser(loginId, password, name, birthDate, email); + public UserInfo createUser(String loginId, String password, String name, String birthDate, String email, Gender gender) { + User user = this.userService.createUser(loginId, password, name, birthDate, email, gender); return UserInfo.from(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 69f82ca5..5a168e52 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -1,5 +1,6 @@ package com.loopers.application.user; +import com.loopers.domain.user.Gender; import com.loopers.domain.user.User; import java.time.LocalDate; @@ -15,7 +16,8 @@ public record UserInfo( String name, String maskedName, LocalDate birthDate, - String email + String email, + Gender gender ) { public static UserInfo from(User user) { return new UserInfo( @@ -23,7 +25,8 @@ public static UserInfo from(User user) { user.getName().getValue(), user.getName().getMaskedValue(), user.getBirthDate().getValue(), - user.getEmail().getValue() + user.getEmail().getValue(), + user.getGender() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 00000000..c281d4f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,5 @@ +package com.loopers.domain.user; + +public enum Gender { + MALE, FEMALE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 5ad97869..dd0f1bc5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,9 +1,17 @@ package com.loopers.domain.user; import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; /** @@ -32,18 +40,26 @@ public class User extends BaseEntity { @Embedded private Email email; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Gender gender; + protected User() {} - private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { + private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email, Gender gender) { this.loginId = loginId; this.password = encodedPassword; this.name = name; this.birthDate = birthDate; this.email = email; + this.gender = gender; } - public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { - return new User(loginId, encodedPassword, name, birthDate, email); + public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email, Gender gender) { + if (gender == null) { + throw new CoreException(UserErrorType.INVALID_GENDER, "성별은 필수입니다."); + } + return new User(loginId, encodedPassword, name, birthDate, email, gender); } public void changePassword(String newEncodedPassword) { @@ -69,4 +85,8 @@ public BirthDate getBirthDate() { public Email getEmail() { return this.email; } + + public Gender getGender() { + return this.gender; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 4aaaddc5..1f786d0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,5 +1,11 @@ package com.loopers.domain.user; +import com.loopers.domain.user.policy.PasswordPolicy; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.Password; +import com.loopers.domain.user.vo.UserName; import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; @@ -24,7 +30,7 @@ public UserService(UserRepository userRepository, PasswordEncryptor passwordEncr } @Transactional - public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { + public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail, Gender gender) { // 1. VO 생성 (각 VO가 자체 규칙 검증) LoginId loginId = new LoginId(rawLoginId); Password password = Password.of(rawPassword); @@ -42,7 +48,7 @@ public User createUser(String rawLoginId, String rawPassword, String rawName, St // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 String encodedPassword = this.passwordEncryptor.encode(rawPassword); - User user = User.create(loginId, encodedPassword, name, birthDate, email); + User user = User.create(loginId, encodedPassword, name, birthDate, email, gender); return this.userRepository.save(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java similarity index 59% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java index 68de671d..a80a21dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicy.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java @@ -1,10 +1,11 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.policy; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.List; /** * 비밀번호 교차 검증 정책 (Utility Class) @@ -23,16 +24,23 @@ public static void validate(String rawPassword, LocalDate birthDate) { if (birthDate == null) { throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); } - validateBirthDateNotContained(rawPassword, birthDate); + validateNotContainsSubstrings(rawPassword, extractBirthDateStrings(birthDate)); } - private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { - String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); - String yymmdd = yyyymmdd.substring(2); - String mmdd = yyyymmdd.substring(4); - - if (rawPassword.contains(yyyymmdd) || rawPassword.contains(yymmdd) || rawPassword.contains(mmdd)) { - throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + public static void validateNotContainsSubstrings(String rawPassword, List forbidden) { + for (String s : forbidden) { + if (rawPassword.contains(s)) { + throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } } } + + public static List extractBirthDateStrings(LocalDate birthDate) { + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + return List.of( + yyyymmdd, + yyyymmdd.substring(2), + yyyymmdd.substring(4) + ); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java index 6220eb78..7c99186c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; @@ -70,4 +70,4 @@ private static void validateRange(LocalDate date) { "만 " + MIN_AGE + "세 이상만 가입할 수 있습니다."); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java index 35a89322..5e5b5e48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; @@ -64,4 +64,4 @@ private void validateNoConsecutiveDots(String value) { "이메일 로컬 파트에 연속된 점(.)을 사용할 수 없습니다."); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java index 725022df..8bc48336 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java similarity index 99% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java index b0ae5a89..c0d1e2bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; @@ -117,4 +117,4 @@ private static void validateNoSequentialChars(String rawPassword) { } } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java index 3596972a..cbd8d4dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.domain.user.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; @@ -58,4 +58,4 @@ private void validate(String value) { "이름은 한글과 영문만 사용할 수 있습니다."); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index 424b8739..bc351058 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -27,7 +27,7 @@ public AuthV1Controller(AuthFacade authFacade) { @Override public ApiResponse createUser(@RequestBody AuthV1Dto.SignupRequest request) { UserInfo info = this.authFacade.createUser( - request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + request.loginId(), request.password(), request.name(), request.birthDate(), request.email(), request.gender() ); return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java index e684e71b..9fb9b12b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.auth; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.Gender; import java.time.LocalDate; @@ -12,13 +13,14 @@ public record SignupRequest( String password, String name, String birthDate, - String email + String email, + Gender gender ) { /** 비밀번호 평문 노출 방지 */ @Override public String toString() { return "SignupRequest[loginId=" + loginId + ", password=*****, name=" + name - + ", birthDate=" + birthDate + ", email=" + email + "]"; + + ", birthDate=" + birthDate + ", email=" + email + ", gender=" + gender + "]"; } } @@ -26,10 +28,11 @@ public record SignupResponse( String loginId, String name, LocalDate birthDate, - String email + String email, + Gender gender ) { public static SignupResponse from(UserInfo info) { - return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email(), info.gender()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index e29e5e27..dc91db63 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.Gender; import java.time.LocalDate; @@ -12,10 +13,11 @@ public record UserResponse( String loginId, String name, LocalDate birthDate, - String email + String email, + Gender gender ) { public static UserResponse from(UserInfo info) { - return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); + return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email(), info.gender()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java index cce2a6fc..4c9368d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -20,6 +20,7 @@ public enum UserErrorType implements ErrorType { INVALID_EMAIL(HttpStatus.BAD_REQUEST, "USER_005", "이메일 형식이 올바르지 않습니다."), SAME_PASSWORD(HttpStatus.BAD_REQUEST, "USER_006", "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "USER_007", "비밀번호에 생년월일을 포함할 수 없습니다."), + INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER_008", "성별 형식이 올바르지 않습니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "USER_101", "인증에 실패했습니다."), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java index 994b2994..6ead925f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -1,7 +1,10 @@ package com.loopers.application.auth; import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.*; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.PasswordEncryptor; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -69,7 +72,7 @@ class Signup { String email = "nahyeon@example.com"; // act - UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email); + UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); // assert assertAll( @@ -77,7 +80,8 @@ class Signup { () -> assertThat(result.name()).isEqualTo("홍길동"), () -> assertThat(result.maskedName()).isEqualTo("홍길*"), () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), - () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") + () -> assertThat(result.email()).isEqualTo("nahyeon@example.com"), + () -> assertThat(result.gender()).isEqualTo(Gender.MALE) ); } @@ -91,7 +95,7 @@ class Signup { String email = "test@example.com"; // act - authFacade.createUser(loginId, password, name, birthDate, email); + authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); // assert assertThat(userRepository.existsByLoginId(loginId)).isTrue(); @@ -101,11 +105,11 @@ class Signup { void 중복된_로그인ID로_가입하면_예외가_발생한다() { // arrange String loginId = "duplicate"; - authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); + authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com", Gender.MALE); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); + authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com", Gender.FEMALE); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -122,7 +126,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, password, name, birthDate, email); + authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -139,7 +143,7 @@ class ChangePassword { String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act authFacade.updateUserPassword(loginId, currentPassword, currentPassword, newPassword); @@ -155,7 +159,7 @@ class ChangePassword { String loginId = "nahyeon"; String oldPassword = "Hx7!mK2@"; String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act - 비밀번호 변경 (authenticateUser → detached entity → updateUserPassword에서 save) authFacade.updateUserPassword(loginId, oldPassword, oldPassword, newPassword); @@ -173,7 +177,7 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -188,7 +192,7 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -203,7 +207,7 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java index b0c3efee..2b1351cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java @@ -1,6 +1,7 @@ package com.loopers.application.user; import com.loopers.application.auth.AuthFacade; +import com.loopers.domain.user.Gender; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -56,7 +57,7 @@ class GetMyInfo { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; - authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act UserInfo result = userFacade.getUser(loginId, password); @@ -90,7 +91,7 @@ class GetMyInfo { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; - authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index bd733e3a..522d0a95 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.BirthDate; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 7c5ee1e4..8f373bac 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.Email; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index 5ecae448..194d15cc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.LoginId; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java index 31482224..7c8befb0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.policy.PasswordPolicy; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 06ff225d..5070bf53 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.Password; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java index 370f5600..81b7a4f6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.UserName; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java index fcb8fc3e..85633d1c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -1,5 +1,9 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -53,7 +57,8 @@ private User createTestUser(String loginIdValue) { "$2a$10$encodedPasswordHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email(loginIdValue + "@example.com") + new Email(loginIdValue + "@example.com"), + Gender.MALE ); } @@ -82,7 +87,7 @@ class Save { BirthDate birthDate = new BirthDate("1994-11-15"); Email email = new Email("nahyeon@example.com"); - User user = User.create(loginId, encodedPassword, name, birthDate, email); + User user = User.create(loginId, encodedPassword, name, birthDate, email, Gender.MALE); // act User savedUser = userRepository.save(user); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index ad6664ff..beb8566f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -1,5 +1,9 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.BeforeEach; @@ -51,7 +55,7 @@ class Signup { when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // act - User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); // assert assertAll( @@ -70,7 +74,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -83,7 +87,7 @@ class Signup { // act & assert - birthDate: 1990-03-25, password contains "19900325" CoreException exception = assertThrows(CoreException.class, () -> { - userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com", Gender.MALE); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -100,7 +104,8 @@ class Authenticate { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); @@ -158,7 +163,8 @@ class Authenticate { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); @@ -182,7 +188,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); @@ -203,12 +210,14 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); User staleUser = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); @@ -237,7 +246,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); CoreException exception = assertThrows(CoreException.class, () -> { @@ -252,7 +262,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); CoreException exception = assertThrows(CoreException.class, () -> { @@ -268,7 +279,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); @@ -286,7 +298,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); @@ -304,7 +317,8 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1990-03-25"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 8a74db77..d62c24c4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -1,5 +1,11 @@ package com.loopers.domain.user; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -8,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * User Entity 단위 테스트 @@ -29,7 +36,7 @@ class Create { Email email = new Email("nahyeon@example.com"); // act - User user = User.create(loginId, encodedPassword, name, birthDate, email); + User user = User.create(loginId, encodedPassword, name, birthDate, email, Gender.MALE); // assert assertAll( @@ -37,9 +44,27 @@ class Create { () -> assertThat(user.getPassword()).isEqualTo(encodedPassword), () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), () -> assertThat(user.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), - () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com"), + () -> assertThat(user.getGender()).isEqualTo(Gender.MALE) ); } + + @Test + void 성별이_null이면_예외가_발생한다() { + // arrange + LoginId loginId = new LoginId("nahyeon"); + String encodedPassword = "$2a$10$encodedPasswordHash"; + UserName name = new UserName("홍길동"); + BirthDate birthDate = new BirthDate("1994-11-15"); + Email email = new Email("nahyeon@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + User.create(loginId, encodedPassword, name, birthDate, email, null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_GENDER); + } } @DisplayName("비밀번호를 변경할 때,") @@ -54,7 +79,8 @@ class ChangePassword { "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") + new Email("nahyeon@example.com"), + Gender.MALE ); String newEncodedPassword = "$2a$10$newHash"; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java index a4ca16da..476abfa2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api; +import com.loopers.domain.user.Gender; import com.loopers.interfaces.api.auth.AuthV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -36,7 +37,7 @@ void tearDown() { } private AuthV1Dto.SignupRequest validSignupRequest() { - return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); } private ResponseEntity signup(AuthV1Dto.SignupRequest request) { @@ -81,7 +82,7 @@ class Signup { void 잘못된_비밀번호_형식이면_400_Bad_Request_응답을_받는다() { // arrange AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( - "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" + "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE ); // act @@ -95,7 +96,7 @@ class Signup { void 비밀번호에_생년월일이_포함되면_400_Bad_Request_응답을_받는다() { // arrange AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( - "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" + "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE ); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 33057c5a..32668c1b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api; +import com.loopers.domain.user.Gender; import com.loopers.interfaces.api.auth.AuthV1Dto; import com.loopers.interfaces.api.user.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -37,7 +38,7 @@ void tearDown() { } private AuthV1Dto.SignupRequest validSignupRequest() { - return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); } private ResponseEntity signup(AuthV1Dto.SignupRequest request) { From 417e4f3ee12ea31ed4c80d98d9a0b617f45a9694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:08:40 +0900 Subject: [PATCH 35/44] =?UTF-8?q?refactor:=20Gender=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=EB=B0=8F=20@AuthUser=20=EC=9D=B8=EC=A6=9D=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/config/WebMvcConfig.java | 23 ++++++++ .../com/loopers/support/auth/AuthUser.java | 11 ++++ .../support/auth/AuthUserResolver.java | 56 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 00000000..ef231cd6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,23 @@ +package com.loopers.config; + +import com.loopers.support.auth.AuthUserResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthUserResolver authUserResolver; + + public WebMvcConfig(AuthUserResolver authUserResolver) { + this.authUserResolver = authUserResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(this.authUserResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java new file mode 100644 index 00000000..a66e38d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java new file mode 100644 index 00000000..9e471c60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java @@ -0,0 +1,56 @@ +package com.loopers.support.auth; + +import com.loopers.domain.user.UserService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; + +/** + * 인증 Argument Resolver + * + * @AuthUser가 붙은 Controller 파라미터에 인증된 User를 주입한다. + * 헤더(X-Loopers-LoginId/Pw)를 추출하고, 인증은 UserService에 위임한다. + */ +@Component +public class AuthUserResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserService userService; + + public AuthUserResolver(UserService userService) { + this.userService = userService; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + return this.userService.authenticateUser(loginId, password); + } +} From edbbd677e5f8833bdcbd3c60bf5f083021e2b241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:09:09 +0900 Subject: [PATCH 36/44] =?UTF-8?q?refactor:=20Gender=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/Gender.java | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java deleted file mode 100644 index c281d4f1..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.loopers.domain.user; - -public enum Gender { - MALE, FEMALE -} From 44913e48ddf0365ad5ae6bc3a3e396e450efa55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:11:01 +0900 Subject: [PATCH 37/44] =?UTF-8?q?refactor:=20Gender=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=EB=B0=8F=20@AuthUser=20=EC=9D=B8=EC=A6=9D=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=20=EB=8F=84=EC=9E=85=20-=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EB=8B=A8=EC=88=9C=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20@Repository=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/auth/AuthFacade.java | 16 +++-- .../loopers/application/user/UserFacade.java | 13 +--- .../loopers/application/user/UserInfo.java | 7 +-- .../java/com/loopers/domain/user/User.java | 22 +------ .../loopers/domain/user/UserRepository.java | 2 +- .../com/loopers/domain/user/UserService.java | 4 +- .../user/UserRepositoryImpl.java | 4 +- .../interfaces/api/auth/AuthV1ApiSpec.java | 5 +- .../interfaces/api/auth/AuthV1Controller.java | 19 +++--- .../{AuthV1Dto.java => AuthV1Request.java} | 26 ++------ .../interfaces/api/auth/AuthV1Response.java | 20 +++++++ .../interfaces/api/user/UserV1ApiSpec.java | 3 +- .../interfaces/api/user/UserV1Controller.java | 21 +++---- .../{UserV1Dto.java => UserV1Response.java} | 10 ++-- .../support/error/CommonErrorType.java | 19 +++--- .../com/loopers/support/error/ErrorType.java | 2 +- .../loopers/support/error/UserErrorType.java | 40 +++++-------- .../auth/AuthFacadeIntegrationTest.java | 59 ++++++++----------- .../user/UserFacadeIntegrationTest.java | 45 +++----------- .../user/UserRepositoryIntegrationTest.java | 5 +- .../loopers/domain/user/UserServiceTest.java | 36 ++++------- .../com/loopers/domain/user/UserTest.java | 28 +-------- .../interfaces/api/AuthV1ApiE2ETest.java | 24 ++++---- .../interfaces/api/UserV1ApiE2ETest.java | 15 +++-- 24 files changed, 166 insertions(+), 279 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/{AuthV1Dto.java => AuthV1Request.java} (55%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/{UserV1Dto.java => UserV1Response.java} (68%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java index d6d9b5f0..1c98a5be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.auth; import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.Gender; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import org.springframework.stereotype.Component; @@ -10,7 +9,6 @@ * 인증 퍼사드 (Application Layer) * * 회원가입, 비밀번호 변경 등 인증 관련 유스케이스를 오케스트레이션한다. - * Entity를 외부에 노출하지 않고 UserInfo DTO로 변환하여 반환한다. */ @Component public class AuthFacade { @@ -20,18 +18,18 @@ public AuthFacade(UserService userService) { this.userService = userService; } - public UserInfo createUser(String loginId, String password, String name, String birthDate, String email, Gender gender) { - User user = this.userService.createUser(loginId, password, name, birthDate, email, gender); + public UserInfo createUser(String loginId, String password, String name, String birthDate, String email) { + User user = this.userService.createUser(loginId, password, name, birthDate, email); return UserInfo.from(user); } /** - * 비밀번호 변경 시 이중 인증을 수행한다. - * 1단계: 헤더 credentials(loginId + headerPassword)로 사용자 인증 - * 2단계: 요청 본문의 currentPassword로 비밀번호 확인 후 변경 + * 비밀번호 변경 + * + * 인증은 AuthUserResolver(@AuthUser)에서 완료된 상태이며, + * 본문의 currentPassword로 2차 확인 후 변경한다. */ - public void updateUserPassword(String loginId, String headerPassword, String currentPassword, String newPassword) { - User user = this.userService.authenticateUser(loginId, headerPassword); + public void updateUserPassword(User user, String currentPassword, String newPassword) { this.userService.updateUserPassword(user, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 028f4c15..f0fff0ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,25 +1,18 @@ package com.loopers.application.user; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; import org.springframework.stereotype.Component; /** * 사용자 퍼사드 (Application Layer) * - * 사용자 정보 조회 유스케이스를 오케스트레이션한다. - * Entity를 외부에 노출하지 않고 UserInfo DTO로 변환하여 반환한다. + * 인증은 AuthUserResolver(@AuthUser)에서 완료된 상태이며, + * Entity → DTO 변환만 담당한다. */ @Component public class UserFacade { - private final UserService userService; - public UserFacade(UserService userService) { - this.userService = userService; - } - - public UserInfo getUser(String loginId, String password) { - User user = this.userService.authenticateUser(loginId, password); + public UserInfo getUser(User user) { return UserInfo.from(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 5a168e52..69f82ca5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -1,6 +1,5 @@ package com.loopers.application.user; -import com.loopers.domain.user.Gender; import com.loopers.domain.user.User; import java.time.LocalDate; @@ -16,8 +15,7 @@ public record UserInfo( String name, String maskedName, LocalDate birthDate, - String email, - Gender gender + String email ) { public static UserInfo from(User user) { return new UserInfo( @@ -25,8 +23,7 @@ public static UserInfo from(User user) { user.getName().getValue(), user.getName().getMaskedValue(), user.getBirthDate().getValue(), - user.getEmail().getValue(), - user.getGender() + user.getEmail().getValue() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index dd0f1bc5..3b39260d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -5,13 +5,9 @@ import com.loopers.domain.user.vo.Email; import com.loopers.domain.user.vo.LoginId; import com.loopers.domain.user.vo.UserName; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.Table; /** @@ -40,26 +36,18 @@ public class User extends BaseEntity { @Embedded private Email email; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 10) - private Gender gender; - protected User() {} - private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email, Gender gender) { + private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { this.loginId = loginId; this.password = encodedPassword; this.name = name; this.birthDate = birthDate; this.email = email; - this.gender = gender; } - public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email, Gender gender) { - if (gender == null) { - throw new CoreException(UserErrorType.INVALID_GENDER, "성별은 필수입니다."); - } - return new User(loginId, encodedPassword, name, birthDate, email, gender); + public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { + return new User(loginId, encodedPassword, name, birthDate, email); } public void changePassword(String newEncodedPassword) { @@ -85,8 +73,4 @@ public BirthDate getBirthDate() { public Email getEmail() { return this.email; } - - public Gender getGender() { - return this.gender; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index a27484ac..21f54eb0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -3,7 +3,7 @@ import java.util.Optional; /** - * 사용자 리포지토리 포트 (Domain Layer) + * 사용자 리포지토리 JPA (Domain Layer) * * 도메인이 인프라(JPA)에 의존하지 않도록 추상화한 인터페이스. * 실제 구현은 Infrastructure 계층의 UserRepositoryImpl이 담당한다. diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 1f786d0d..52b35ad8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -30,7 +30,7 @@ public UserService(UserRepository userRepository, PasswordEncryptor passwordEncr } @Transactional - public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail, Gender gender) { + public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { // 1. VO 생성 (각 VO가 자체 규칙 검증) LoginId loginId = new LoginId(rawLoginId); Password password = Password.of(rawPassword); @@ -48,7 +48,7 @@ public User createUser(String rawLoginId, String rawPassword, String rawName, St // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 String encodedPassword = this.passwordEncryptor.encode(rawPassword); - User user = User.create(loginId, encodedPassword, name, birthDate, email, gender); + User user = User.create(loginId, encodedPassword, name, birthDate, email); return this.userRepository.save(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index ed1632e1..58e197b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -2,7 +2,7 @@ import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; import java.util.Optional; @@ -11,7 +11,7 @@ * * 도메인 포트(UserRepository)의 구현체로, Spring Data JPA에 위임한다. */ -@Component +@Repository public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java index 98dfeca9..c321e3a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.auth; +import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -8,8 +9,8 @@ public interface AuthV1ApiSpec { @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") - ApiResponse createUser(AuthV1Dto.SignupRequest request); + ApiResponse createUser(AuthV1Request.SignupRequest request); @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") - ApiResponse updateUserPassword(String loginId, String loginPw, AuthV1Dto.ChangePasswordRequest request); + ApiResponse updateUserPassword(User user, AuthV1Request.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java index bc351058..e0b5ef71 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java @@ -2,15 +2,17 @@ import com.loopers.application.auth.AuthFacade; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUser; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; /** * 인증 API 컨트롤러 (V1) * - * 인증 방식: 커스텀 헤더(X-Loopers-LoginId, X-Loopers-LoginPw) 기반 인증. - * 비밀번호 변경 시 헤더 인증 + 본문 비밀번호 확인의 이중 검증을 수행한다. + * 회원가입: 인증 불필요 (RequestBody만 사용) + * 비밀번호 변경: @AuthUser로 헤더 인증 + RequestBody의 currentPassword로 2차 확인 */ @RestController @RequestMapping("/api/v1/auth") @@ -25,21 +27,20 @@ public AuthV1Controller(AuthFacade authFacade) { @PostMapping("/signup") @ResponseStatus(HttpStatus.CREATED) @Override - public ApiResponse createUser(@RequestBody AuthV1Dto.SignupRequest request) { + public ApiResponse createUser(@RequestBody AuthV1Request.SignupRequest request) { UserInfo info = this.authFacade.createUser( - request.loginId(), request.password(), request.name(), request.birthDate(), request.email(), request.gender() + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() ); - return ApiResponse.success(AuthV1Dto.SignupResponse.from(info)); + return ApiResponse.success(AuthV1Response.SignupResponse.from(info)); } @PutMapping("/password") @Override public ApiResponse updateUserPassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw, - @RequestBody AuthV1Dto.ChangePasswordRequest request + @AuthUser User user, + @RequestBody AuthV1Request.ChangePasswordRequest request ) { - this.authFacade.updateUserPassword(loginId, loginPw, request.currentPassword(), request.newPassword()); + this.authFacade.updateUserPassword(user, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java similarity index 55% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java index 9fb9b12b..b270245b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java @@ -1,38 +1,20 @@ package com.loopers.interfaces.api.auth; -import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.Gender; - -import java.time.LocalDate; - -/** 인증 API V1 요청/응답 DTO */ -public class AuthV1Dto { +/** 인증 API V1 요청 DTO */ +public class AuthV1Request { public record SignupRequest( String loginId, String password, String name, String birthDate, - String email, - Gender gender + String email ) { /** 비밀번호 평문 노출 방지 */ @Override public String toString() { return "SignupRequest[loginId=" + loginId + ", password=*****, name=" + name - + ", birthDate=" + birthDate + ", email=" + email + ", gender=" + gender + "]"; - } - } - - public record SignupResponse( - String loginId, - String name, - LocalDate birthDate, - String email, - Gender gender - ) { - public static SignupResponse from(UserInfo info) { - return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email(), info.gender()); + + ", birthDate=" + birthDate + ", email=" + email + "]"; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java new file mode 100644 index 00000000..cd838307 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +/** 인증 API V1 응답 DTO */ +public class AuthV1Response { + + public record SignupResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static SignupResponse from(UserInfo info) { + return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 7f91f217..403f71f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -8,5 +9,5 @@ public interface UserV1ApiSpec { @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") - ApiResponse getUser(String loginId, String loginPw); + ApiResponse getUser(User user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 558e28a6..3f5f3bc0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,14 +2,14 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; -import org.springframework.web.bind.annotation.*; +import com.loopers.support.auth.AuthUser; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -/** - * 사용자 API 컨트롤러 (V1) - * - * 인증 방식: 커스텀 헤더(X-Loopers-LoginId, X-Loopers-LoginPw) 기반 인증. - */ +/** 사용자 API 컨트롤러 (V1) */ @RestController @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { @@ -22,11 +22,8 @@ public UserV1Controller(UserFacade userFacade) { @GetMapping("/me") @Override - public ApiResponse getUser( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String loginPw - ) { - UserInfo info = this.userFacade.getUser(loginId, loginPw); - return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + public ApiResponse getUser(@AuthUser User user) { + UserInfo info = this.userFacade.getUser(user); + return ApiResponse.success(UserV1Response.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java similarity index 68% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java index dc91db63..c309c38a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java @@ -1,23 +1,21 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.Gender; import java.time.LocalDate; -/** 사용자 API V1 요청/응답 DTO */ -public class UserV1Dto { +/** 사용자 API V1 응답 DTO */ +public class UserV1Response { /** 이름은 개인정보 보호를 위해 maskedName으로 반환한다 */ public record UserResponse( String loginId, String name, LocalDate birthDate, - String email, - Gender gender + String email ) { public static UserResponse from(UserInfo info) { - return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email(), info.gender()); + return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java index d1eba755..25b9dfad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -2,21 +2,18 @@ import org.springframework.http.HttpStatus; -/** 시스템 공통 에러 타입 (도메인에 속하지 않는 범용 에러) */ public enum CommonErrorType implements ErrorType { - INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."); + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, "이미 존재하는 리소스입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."); private final HttpStatus status; - private final String code; private final String message; - CommonErrorType(HttpStatus status, String code, String message) { + CommonErrorType(HttpStatus status, String message) { this.status = status; - this.code = code; this.message = message; } @@ -27,11 +24,11 @@ public HttpStatus getStatus() { @Override public String getCode() { - return this.code; + return name(); } @Override public String getMessage() { return this.message; } -} \ No newline at end of file +} 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 c81a4046..19f1e95a 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 @@ -6,7 +6,7 @@ * 에러 타입 인터페이스 * * 모든 에러 열거형(UserErrorType, CommonErrorType 등)이 구현하는 공통 계약. - * ApiControllerAdvice에서 HTTP 상태, 에러 코드, 메시지를 일관되게 추출한다. + * getCode()는 enum의 name()을 반환하여 별도 코드 관리 없이 식별한다. */ public interface ErrorType { HttpStatus getStatus(); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java index 4c9368d1..70610af0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -2,43 +2,31 @@ import org.springframework.http.HttpStatus; -/** - * 사용자 도메인 에러 타입 - * - * 에러 코드 체계: USER_{카테고리}{순번} - * - 0xx: 입력값 검증 실패 (400) - * - 1xx: 인증 실패 (401) - * - 2xx: 리소스 미존재 (404) - * - 3xx: 충돌 (409) - */ public enum UserErrorType implements ErrorType { // 400 Bad Request - INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "USER_001", "로그인 ID 형식이 올바르지 않습니다."), - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_002", "비밀번호 형식이 올바르지 않습니다."), - INVALID_NAME(HttpStatus.BAD_REQUEST, "USER_003", "이름 형식이 올바르지 않습니다."), - INVALID_BIRTH_DATE(HttpStatus.BAD_REQUEST, "USER_004", "생년월일 형식이 올바르지 않습니다."), - INVALID_EMAIL(HttpStatus.BAD_REQUEST, "USER_005", "이메일 형식이 올바르지 않습니다."), - SAME_PASSWORD(HttpStatus.BAD_REQUEST, "USER_006", "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), - PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "USER_007", "비밀번호에 생년월일을 포함할 수 없습니다."), - INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER_008", "성별 형식이 올바르지 않습니다."), + INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "로그인 ID 형식이 올바르지 않습니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호 형식이 올바르지 않습니다."), + INVALID_NAME(HttpStatus.BAD_REQUEST, "이름 형식이 올바르지 않습니다."), + INVALID_BIRTH_DATE(HttpStatus.BAD_REQUEST, "생년월일 형식이 올바르지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."), + SAME_PASSWORD(HttpStatus.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), + PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."), // 401 Unauthorized - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "USER_101", "인증에 실패했습니다."), - PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "USER_102", "비밀번호가 일치하지 않습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), // 404 Not Found - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_201", "존재하지 않는 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), // 409 Conflict - DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "USER_301", "이미 사용 중인 로그인 ID입니다."); + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "이미 사용 중인 로그인 ID입니다."); private final HttpStatus status; - private final String code; private final String message; - UserErrorType(HttpStatus status, String code, String message) { + UserErrorType(HttpStatus status, String message) { this.status = status; - this.code = code; this.message = message; } @@ -49,11 +37,11 @@ public HttpStatus getStatus() { @Override public String getCode() { - return this.code; + return name(); } @Override public String getMessage() { return this.message; } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java index 6ead925f..ed2dc718 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java @@ -1,10 +1,10 @@ package com.loopers.application.auth; import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.Gender; import com.loopers.domain.user.PasswordEncryptor; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -44,6 +44,9 @@ class AuthFacadeIntegrationTest { @Autowired private UserRepository userRepository; + @Autowired + private UserService userService; + @Autowired private PasswordEncryptor passwordEncryptor; @@ -72,7 +75,7 @@ class Signup { String email = "nahyeon@example.com"; // act - UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); + UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email); // assert assertAll( @@ -80,8 +83,7 @@ class Signup { () -> assertThat(result.name()).isEqualTo("홍길동"), () -> assertThat(result.maskedName()).isEqualTo("홍길*"), () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), - () -> assertThat(result.email()).isEqualTo("nahyeon@example.com"), - () -> assertThat(result.gender()).isEqualTo(Gender.MALE) + () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") ); } @@ -95,7 +97,7 @@ class Signup { String email = "test@example.com"; // act - authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); + authFacade.createUser(loginId, password, name, birthDate, email); // assert assertThat(userRepository.existsByLoginId(loginId)).isTrue(); @@ -105,11 +107,11 @@ class Signup { void 중복된_로그인ID로_가입하면_예외가_발생한다() { // arrange String loginId = "duplicate"; - authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com", Gender.MALE); + authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com", Gender.FEMALE); + authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -126,7 +128,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, password, name, birthDate, email, Gender.MALE); + authFacade.createUser(loginId, password, name, birthDate, email); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -143,14 +145,15 @@ class ChangePassword { String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser(loginId, currentPassword); // act - authFacade.updateUserPassword(loginId, currentPassword, currentPassword, newPassword); + authFacade.updateUserPassword(user, currentPassword, newPassword); // assert - 새 비밀번호로 인증 성공 확인 - User user = userRepository.findByLoginId(loginId).orElseThrow(); - assertThat(passwordEncryptor.matches(newPassword, user.getPassword())).isTrue(); + User reloaded = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(passwordEncryptor.matches(newPassword, reloaded.getPassword())).isTrue(); } @Test @@ -159,10 +162,11 @@ class ChangePassword { String loginId = "nahyeon"; String oldPassword = "Hx7!mK2@"; String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser(loginId, oldPassword); - // act - 비밀번호 변경 (authenticateUser → detached entity → updateUserPassword에서 save) - authFacade.updateUserPassword(loginId, oldPassword, oldPassword, newPassword); + // act + authFacade.updateUserPassword(user, oldPassword, newPassword); // assert - DB에서 재조회하여 새 비밀번호로 인증 성공 확인 User reloadedUser = userRepository.findByLoginId(loginId).orElseThrow(); @@ -172,31 +176,17 @@ class ChangePassword { ); } - @Test - void 헤더_비밀번호가_틀리면_인증_예외가_발생한다() { - // arrange - String loginId = "nahyeon"; - String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.updateUserPassword(loginId, "wrongPw1!", currentPassword, "Nw8@pL3#"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); - } - @Test void 현재_비밀번호가_틀리면_예외가_발생한다() { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser(loginId, currentPassword); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.updateUserPassword(loginId, currentPassword, "wrongPw1!", "Nw8@pL3#"); + authFacade.updateUserPassword(user, "wrongPw1!", "Nw8@pL3#"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); @@ -207,11 +197,12 @@ class ChangePassword { // arrange String loginId = "nahyeon"; String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser(loginId, currentPassword); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.updateUserPassword(loginId, currentPassword, currentPassword, currentPassword); + authFacade.updateUserPassword(user, currentPassword, currentPassword); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java index 2b1351cd..e4b8692f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java @@ -1,9 +1,8 @@ package com.loopers.application.user; import com.loopers.application.auth.AuthFacade; -import com.loopers.domain.user.Gender; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +17,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; /** * [Application Layer - UserFacade 통합 테스트] @@ -40,6 +38,9 @@ class UserFacadeIntegrationTest { @Autowired private AuthFacade authFacade; + @Autowired + private UserService userService; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -53,14 +54,15 @@ void tearDown() { class GetMyInfo { @Test - void 유효한_인증_정보면_사용자_정보를_반환한다() { + void 인증된_사용자_정보를_반환한다() { // arrange String loginId = "nahyeon"; String password = "Hx7!mK2@"; - authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser(loginId, password); // act - UserInfo result = userFacade.getUser(loginId, password); + UserInfo result = userFacade.getUser(user); // assert assertAll( @@ -71,34 +73,5 @@ class GetMyInfo { () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") ); } - - @Test - void 존재하지_않는_사용자면_예외가_발생한다() { - // arrange - String loginId = "nonexistent"; - String password = "Hx7!mK2@"; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - userFacade.getUser(loginId, password); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); - } - - @Test - void 비밀번호가_틀리면_예외가_발생한다() { - // arrange - String loginId = "nahyeon"; - String password = "Hx7!mK2@"; - authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - userFacade.getUser(loginId, "wrongPw1!"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); - } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java index 85633d1c..f65c44e2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -57,8 +57,7 @@ private User createTestUser(String loginIdValue) { "$2a$10$encodedPasswordHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email(loginIdValue + "@example.com"), - Gender.MALE + new Email(loginIdValue + "@example.com") ); } @@ -87,7 +86,7 @@ class Save { BirthDate birthDate = new BirthDate("1994-11-15"); Email email = new Email("nahyeon@example.com"); - User user = User.create(loginId, encodedPassword, name, birthDate, email, Gender.MALE); + User user = User.create(loginId, encodedPassword, name, birthDate, email); // act User savedUser = userRepository.save(user); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index beb8566f..d1da01a9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -55,7 +55,7 @@ class Signup { when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // act - User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); // assert assertAll( @@ -74,7 +74,7 @@ class Signup { // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); @@ -87,7 +87,7 @@ class Signup { // act & assert - birthDate: 1990-03-25, password contains "19900325" CoreException exception = assertThrows(CoreException.class, () -> { - userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com", Gender.MALE); + userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); @@ -104,8 +104,7 @@ class Authenticate { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); @@ -163,8 +162,7 @@ class Authenticate { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); @@ -188,8 +186,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); @@ -210,14 +207,12 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); User staleUser = User.create( new LoginId("nahyeon"), "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); @@ -246,8 +241,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); CoreException exception = assertThrows(CoreException.class, () -> { @@ -262,8 +256,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); CoreException exception = assertThrows(CoreException.class, () -> { @@ -279,8 +272,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); @@ -298,8 +290,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); @@ -317,8 +308,7 @@ class ChangePassword { User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1990-03-25"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index d62c24c4..44fe2215 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -4,8 +4,6 @@ import com.loopers.domain.user.vo.Email; import com.loopers.domain.user.vo.LoginId; import com.loopers.domain.user.vo.UserName; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -14,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; /** * User Entity 단위 테스트 @@ -36,7 +33,7 @@ class Create { Email email = new Email("nahyeon@example.com"); // act - User user = User.create(loginId, encodedPassword, name, birthDate, email, Gender.MALE); + User user = User.create(loginId, encodedPassword, name, birthDate, email); // assert assertAll( @@ -44,27 +41,9 @@ class Create { () -> assertThat(user.getPassword()).isEqualTo(encodedPassword), () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), () -> assertThat(user.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), - () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com"), - () -> assertThat(user.getGender()).isEqualTo(Gender.MALE) + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") ); } - - @Test - void 성별이_null이면_예외가_발생한다() { - // arrange - LoginId loginId = new LoginId("nahyeon"); - String encodedPassword = "$2a$10$encodedPasswordHash"; - UserName name = new UserName("홍길동"); - BirthDate birthDate = new BirthDate("1994-11-15"); - Email email = new Email("nahyeon@example.com"); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - User.create(loginId, encodedPassword, name, birthDate, email, null); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_GENDER); - } } @DisplayName("비밀번호를 변경할 때,") @@ -79,8 +58,7 @@ class ChangePassword { "$2a$10$oldHash", new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com"), - Gender.MALE + new Email("nahyeon@example.com") ); String newEncodedPassword = "$2a$10$newHash"; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java index 476abfa2..e6835e84 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api; -import com.loopers.domain.user.Gender; -import com.loopers.interfaces.api.auth.AuthV1Dto; +import com.loopers.interfaces.api.auth.AuthV1Request; +import com.loopers.interfaces.api.auth.AuthV1Response; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -36,11 +36,11 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private AuthV1Dto.SignupRequest validSignupRequest() { - return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + private AuthV1Request.SignupRequest validSignupRequest() { + return new AuthV1Request.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); } - private ResponseEntity signup(AuthV1Dto.SignupRequest request) { + private ResponseEntity signup(AuthV1Request.SignupRequest request) { return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); } @@ -81,8 +81,8 @@ class Signup { @Test void 잘못된_비밀번호_형식이면_400_Bad_Request_응답을_받는다() { // arrange - AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( - "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE + AuthV1Request.SignupRequest request = new AuthV1Request.SignupRequest( + "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" ); // act @@ -95,8 +95,8 @@ class Signup { @Test void 비밀번호에_생년월일이_포함되면_400_Bad_Request_응답을_받는다() { // arrange - AuthV1Dto.SignupRequest request = new AuthV1Dto.SignupRequest( - "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE + AuthV1Request.SignupRequest request = new AuthV1Request.SignupRequest( + "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" ); // act @@ -119,7 +119,7 @@ class ChangePassword { signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); // act ResponseEntity response = testRestTemplate.exchange( @@ -135,7 +135,7 @@ class ChangePassword { signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // act - 새 비밀번호로 조회 @@ -161,7 +161,7 @@ class ChangePassword { signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Dto.ChangePasswordRequest body = new AuthV1Dto.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); + AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); // act ResponseEntity response = testRestTemplate.exchange( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 32668c1b..4a23ec5f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -1,8 +1,7 @@ package com.loopers.interfaces.api; -import com.loopers.domain.user.Gender; -import com.loopers.interfaces.api.auth.AuthV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.interfaces.api.auth.AuthV1Request; +import com.loopers.interfaces.api.user.UserV1Response; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -37,11 +36,11 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private AuthV1Dto.SignupRequest validSignupRequest() { - return new AuthV1Dto.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com", Gender.MALE); + private AuthV1Request.SignupRequest validSignupRequest() { + return new AuthV1Request.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); } - private ResponseEntity signup(AuthV1Dto.SignupRequest request) { + private ResponseEntity signup(AuthV1Request.SignupRequest request) { return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); } @@ -65,8 +64,8 @@ class GetMyInfo { HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); // act - ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), type); // assert From ea8cbebaca17527b44cb9f30d448e1fa32c06f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:55:47 +0900 Subject: [PATCH 38/44] =?UTF-8?q?fix:=20Password=20VO=20=EA=B3=BC=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20PasswordPolicy=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserService.java | 34 ++--- .../domain/user/policy/PasswordPolicy.java | 46 ------ .../com/loopers/domain/user/vo/Password.java | 59 +------- .../domain/user/PasswordPolicyTest.java | 92 ------------ .../com/loopers/domain/user/PasswordTest.java | 139 ++---------------- .../loopers/domain/user/UserServiceTest.java | 27 ---- 6 files changed, 34 insertions(+), 363 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 52b35ad8..3873b70e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.domain.user.policy.PasswordPolicy; import com.loopers.domain.user.vo.BirthDate; import com.loopers.domain.user.vo.Email; import com.loopers.domain.user.vo.LoginId; @@ -12,11 +11,13 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + /** * 사용자 도메인 서비스 * * 회원가입, 인증, 비밀번호 변경 등 사용자 도메인 핵심 비즈니스 로직을 담당한다. - * VO 자체 검증 → 교차 검증(PasswordPolicy) → 저장 순서로 처리한다. */ @Component public class UserService { @@ -31,22 +32,19 @@ public UserService(UserRepository userRepository, PasswordEncryptor passwordEncr @Transactional public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { - // 1. VO 생성 (각 VO가 자체 규칙 검증) LoginId loginId = new LoginId(rawLoginId); Password password = Password.of(rawPassword); UserName name = new UserName(rawName); BirthDate birthDate = new BirthDate(rawBirthDate); Email email = new Email(rawEmail); - // 2. 교차 검증 (비밀번호에 생년월일 포함 불가) - PasswordPolicy.validate(rawPassword, birthDate.getValue()); + // 비밀번호에 생년월일 포함 불가 + validatePasswordNotContainsBirthDate(rawPassword, birthDate.getValue()); - // 3. 중복 ID 검증 if (this.userRepository.existsByLoginId(loginId.getValue())) { throw new CoreException(UserErrorType.DUPLICATE_LOGIN_ID); } - // 4. 비밀번호 암호화 + 엔티티 생성 + 저장 String encodedPassword = this.passwordEncryptor.encode(rawPassword); User user = User.create(loginId, encodedPassword, name, birthDate, email); return this.userRepository.save(user); @@ -83,30 +81,30 @@ public void updateUserPassword(User user, String currentRawPassword, String newR throw new CoreException(UserErrorType.INVALID_PASSWORD, "새 비밀번호는 필수입니다."); } - // 현재 비밀번호 확인 if (!this.passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.PASSWORD_MISMATCH); } - // 새 비밀번호 규칙 검증 Password.of(newRawPassword); + validatePasswordNotContainsBirthDate(newRawPassword, user.getBirthDate().getValue()); - // 교차 검증 - PasswordPolicy.validate(newRawPassword, user.getBirthDate().getValue()); - - // 동일 비밀번호 확인 if (this.passwordEncryptor.matches(newRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.SAME_PASSWORD); } - // 암호화 후 변경 및 저장 (detached 엔티티 대응) String newEncodedPassword = this.passwordEncryptor.encode(newRawPassword); user.changePassword(newEncodedPassword); - User savedUser = this.userRepository.save(user); + this.userRepository.save(user); + } - // 영속화 검증: 저장된 엔티티의 비밀번호가 정상 반영되었는지 확인 - if (!savedUser.getPassword().equals(newEncodedPassword)) { - throw new CoreException(CommonErrorType.INTERNAL_ERROR, "비밀번호 변경이 정상적으로 반영되지 않았습니다."); + /** 비밀번호에 생년월일(YYYYMMDD, YYMMDD, MMDD) 포함 금지 */ + private void validatePasswordNotContainsBirthDate(String rawPassword, LocalDate birthDate) { + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String[] patterns = { yyyymmdd, yyyymmdd.substring(2), yyyymmdd.substring(4) }; + for (String pattern : patterns) { + if (rawPassword.contains(pattern)) { + throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java deleted file mode 100644 index a80a21dd..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.domain.user.policy; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.List; - -/** - * 비밀번호 교차 검증 정책 (Utility Class) - * - * Password VO 자체로는 판단할 수 없는, 다른 도메인 값과의 관계를 검증한다. - * - 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) - */ -public final class PasswordPolicy { - - private PasswordPolicy() {} - - public static void validate(String rawPassword, LocalDate birthDate) { - if (rawPassword == null || rawPassword.isBlank()) { - throw new CoreException(UserErrorType.INVALID_PASSWORD, "비밀번호는 필수입니다."); - } - if (birthDate == null) { - throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); - } - validateNotContainsSubstrings(rawPassword, extractBirthDateStrings(birthDate)); - } - - public static void validateNotContainsSubstrings(String rawPassword, List forbidden) { - for (String s : forbidden) { - if (rawPassword.contains(s)) { - throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); - } - } - } - - public static List extractBirthDateStrings(LocalDate birthDate) { - String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); - return List.of( - yyyymmdd, - yyyymmdd.substring(2), - yyyymmdd.substring(4) - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java index c0d1e2bd..558117a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java @@ -10,21 +10,18 @@ * * raw 비밀번호의 자체 규칙만 검증한다. * - 암호화는 Service 레이어에서 담당 - * - 교차 검증(생년월일 포함 금지)은 PasswordPolicy Domain Service에서 담당 + * - 교차 검증(생년월일 포함 금지)은 UserService에서 담당 * * 검증 규칙: * - 8~16자 - * - 영문 대소문자, 숫자, 특수문자만 허용 (공백, 한글 등 불가) + * - 영문 대소문자, 숫자, 특수문자만 허용 * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 - * - 동일 문자 3회 이상 연속 금지 (대소문자 구분 없음) - * - 연속된 문자/숫자 3자리 이상 금지 (abc, 123 등) */ public class Password { private static final int MIN_LENGTH = 8; private static final int MAX_LENGTH = 16; private static final int MIN_COMPLEXITY = 3; - private static final int CONSECUTIVE_LIMIT = 3; private static final Pattern ALLOWED_CHARS = Pattern.compile("^[!-~]+$"); private final String value; @@ -43,33 +40,18 @@ public String getValue() { } private static void validate(String rawPassword) { - validateNotBlank(rawPassword); - validateLength(rawPassword); - validateAllowedChars(rawPassword); - validateComplexity(rawPassword); - validateNoConsecutiveSameChars(rawPassword); - validateNoSequentialChars(rawPassword); - } - - private static void validateNotBlank(String rawPassword) { if (rawPassword == null || rawPassword.isBlank()) { throw new CoreException(UserErrorType.INVALID_PASSWORD); } - } - private static void validateLength(String rawPassword) { if (rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { throw new CoreException(UserErrorType.INVALID_PASSWORD); } - } - private static void validateAllowedChars(String rawPassword) { if (!ALLOWED_CHARS.matcher(rawPassword).matches()) { throw new CoreException(UserErrorType.INVALID_PASSWORD); } - } - private static void validateComplexity(String rawPassword) { int typeCount = 0; if (rawPassword.chars().anyMatch(Character::isUpperCase)) typeCount++; if (rawPassword.chars().anyMatch(Character::isLowerCase)) typeCount++; @@ -80,41 +62,4 @@ private static void validateComplexity(String rawPassword) { throw new CoreException(UserErrorType.INVALID_PASSWORD); } } - - private static void validateNoConsecutiveSameChars(String rawPassword) { - String lower = rawPassword.toLowerCase(); - for (int i = 0; i <= lower.length() - CONSECUTIVE_LIMIT; i++) { - char target = lower.charAt(i); - boolean allSame = true; - for (int j = 1; j < CONSECUTIVE_LIMIT; j++) { - if (lower.charAt(i + j) != target) { - allSame = false; - break; - } - } - if (allSame) { - throw new CoreException(UserErrorType.INVALID_PASSWORD); - } - } - } - - private static void validateNoSequentialChars(String rawPassword) { - String lower = rawPassword.toLowerCase(); - for (int i = 0; i <= lower.length() - CONSECUTIVE_LIMIT; i++) { - char c1 = lower.charAt(i); - char c2 = lower.charAt(i + 1); - char c3 = lower.charAt(i + 2); - - boolean sameType = (Character.isLetter(c1) && Character.isLetter(c2) && Character.isLetter(c3)) - || (Character.isDigit(c1) && Character.isDigit(c2) && Character.isDigit(c3)); - - if (sameType) { - boolean ascending = (c2 - c1 == 1) && (c3 - c2 == 1); - boolean descending = (c1 - c2 == 1) && (c2 - c3 == 1); - if (ascending || descending) { - throw new CoreException(UserErrorType.INVALID_PASSWORD); - } - } - } - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java deleted file mode 100644 index 7c8befb0..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.user.policy.PasswordPolicy; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; -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.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * PasswordPolicy 교차 검증 단위 테스트 - * - * 검증 규칙: - * - 비밀번호에 생년월일 포함 금지 (YYYYMMDD, YYMMDD, MMDD) - */ -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -public class PasswordPolicyTest { - - private static final LocalDate BIRTH_DATE = LocalDate.of(1994, 11, 15); - - @DisplayName("비밀번호 교차 검증 시,") - @Nested - class Validate { - - @Test - void 생년월일이_포함되지_않으면_정상_통과한다() { - assertDoesNotThrow(() -> PasswordPolicy.validate("Hx7!mK2@", BIRTH_DATE)); - } - - @Test - void 생년월일_YYYYMMDD가_포함되면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate("A19941115!", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); - } - - @Test - void 생년월일_YYMMDD가_포함되면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate("A941115!a", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); - } - - @Test - void 생년월일_MMDD가_포함되면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate("Abcd1115!", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); - } - - @Test - void 비밀번호가_null이면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate(null, BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); - } - - @Test - void 비밀번호가_빈_문자열이면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate(" ", BIRTH_DATE); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); - } - - @Test - void 생년월일이_null이면_예외가_발생한다() { - CoreException exception = assertThrows(CoreException.class, () -> { - PasswordPolicy.validate("Hx7!mK2@", null); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java index 5070bf53..7723ee82 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -13,18 +13,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; -/** - * Password Value Object 단위 테스트 - * - * 검증 규칙: - * - 8~16자 - * - 영문 대소문자, 숫자, 특수문자만 허용 - * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 - * - 동일 문자 3회 이상 연속 금지 - * - 연속된 문자/숫자 3자리 이상 금지 - * - * 교차 검증(생년월일 포함 금지)은 PasswordPolicy에서 별도 테스트 - */ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class PasswordTest { @@ -32,175 +20,80 @@ public class PasswordTest { @Nested class Create { - // ========== 정상 케이스 ========== - @Test void 모든_규칙을_만족하면_정상적으로_생성된다() { - // arrange - String rawPassword = "Hx7!mK2@"; - - // act - Password password = Password.of(rawPassword); - - // assert - assertThat(password.getValue()).isEqualTo(rawPassword); + Password password = Password.of("Hx7!mK2@"); + assertThat(password.getValue()).isEqualTo("Hx7!mK2@"); } @Test void 최소_길이_8자이면_정상적으로_생성된다() { - // arrange - String rawPassword = "Xz5!qw9@"; // 8자 - - // act - Password password = Password.of(rawPassword); - - // assert - assertThat(password.getValue()).isEqualTo(rawPassword); + Password password = Password.of("Xz5!qw9@"); + assertThat(password.getValue()).isEqualTo("Xz5!qw9@"); } @Test void 최대_길이_16자이면_정상적으로_생성된다() { - // arrange - String rawPassword = "Px8!Kd3@Wm7#Rf2$"; // 16자 - - // act - Password password = Password.of(rawPassword); - - // assert - assertThat(password.getValue()).isEqualTo(rawPassword); + Password password = Password.of("Px8!Kd3@Wm7#Rf2$"); + assertThat(password.getValue()).isEqualTo("Px8!Kd3@Wm7#Rf2$"); } @Test void 다양한_특수문자_조합이면_정상적으로_생성된다() { - // arrange - String rawPassword = "Ac1~`[]{}"; - - // act & assert - assertDoesNotThrow(() -> Password.of(rawPassword)); + assertDoesNotThrow(() -> Password.of("Ac1~`[]{}")); } - // ========== 엣지 케이스 ========== - @Test void null이면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of(null); - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of(null)); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 빈_문자열이면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of(""); - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 길이가_7자_최소_미만이면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd12!"); // 7자 - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd12!")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 길이가_17자_최대_초과이면_예외가_발생한다() { - // arrange - String rawPassword = "Px8!Kd3@Wm7#Rf2$A"; // 17자 - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of(rawPassword); - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Px8!Kd3@Wm7#Rf2$A")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 영문만_있으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcdefgh"); // 대문자+소문자 = 2종 - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcdefgh")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 숫자만_있으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("12345978"); // 숫자 = 1종 - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("12345978")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 특수문자만_있으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("!@#$%^&*"); // 특수문자 = 1종 - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("!@#$%^&*")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 한글이_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd123가"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd123가")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } @Test void 공백이_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abcd 12!"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); - } - - @Test - void 동일_문자가_3회_이상_연속되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Aaab123!@"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); - } - - @Test - void 연속_숫자_3자리가_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("Abzx1234!"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); - } - - @Test - void 연속_문자_3자리가_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - Password.of("xAbcz12!@"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd 12!")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index d1da01a9..e8dbf9ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -16,7 +16,6 @@ import java.util.Optional; -import com.loopers.support.error.CommonErrorType; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -201,32 +200,6 @@ class ChangePassword { assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); } - @Test - void 저장_후_비밀번호가_반영되지_않으면_시스템_예외가_발생한다() { - // arrange - save()가 변경 전 비밀번호를 가진 엔티티를 반환하는 비정상 시나리오 - User user = User.create( - new LoginId("nahyeon"), "$2a$10$oldHash", - new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") - ); - User staleUser = User.create( - new LoginId("nahyeon"), "$2a$10$oldHash", - new UserName("홍길동"), new BirthDate("1994-11-15"), - new Email("nahyeon@example.com") - ); - when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); - when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); - when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); - when(userRepository.save(any(User.class))).thenReturn(staleUser); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); - }); - - assertThat(exception.getErrorType()).isEqualTo(CommonErrorType.INTERNAL_ERROR); - } - @Test void 사용자가_null이면_예외가_발생한다() { CoreException exception = assertThrows(CoreException.class, () -> { From 75032b46be2f0121bc7c4a69996e721cdfe28a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:56:44 +0900 Subject: [PATCH 39/44] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20UserFacade=20=EA=B3=84=EC=B8=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20=ED=8C=A8=EC=8A=A4=EC=8A=A4=EB=A3=A8=EB=A7=8C?= =?UTF-8?q?=20=EC=88=98=ED=96=89=ED=95=98=EB=8A=94=20UserFacade=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20-=20Controller=EC=97=90=EC=84=9C=20UserInf?= =?UTF-8?q?o.from()=20=EC=A7=81=EC=A0=91=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 18 ----- .../interfaces/api/user/UserV1Controller.java | 10 +-- .../user/UserFacadeIntegrationTest.java | 77 ------------------- 3 files changed, 1 insertion(+), 104 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java deleted file mode 100644 index f0fff0ee..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; -import org.springframework.stereotype.Component; - -/** - * 사용자 퍼사드 (Application Layer) - * - * 인증은 AuthUserResolver(@AuthUser)에서 완료된 상태이며, - * Entity → DTO 변환만 담당한다. - */ -@Component -public class UserFacade { - - public UserInfo getUser(User user) { - return UserInfo.from(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 3f5f3bc0..90c91aa3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; @@ -14,16 +13,9 @@ @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - private final UserFacade userFacade; - - public UserV1Controller(UserFacade userFacade) { - this.userFacade = userFacade; - } - @GetMapping("/me") @Override public ApiResponse getUser(@AuthUser User user) { - UserInfo info = this.userFacade.getUser(user); - return ApiResponse.success(UserV1Response.UserResponse.from(info)); + return ApiResponse.success(UserV1Response.UserResponse.from(UserInfo.from(user))); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java deleted file mode 100644 index e4b8692f..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeIntegrationTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.application.auth.AuthFacade; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; -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 java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -/** - * [Application Layer - UserFacade 통합 테스트] - * - * UserFacade의 비즈니스 흐름을 검증하는 통합 테스트. - * Spring Context를 로드하고 실제 DB를 사용하여 오케스트레이션 로직을 검증한다. - * - * 테스트 범위: - * - UserFacade → UserService → UserRepository → DB - * - 실제 비즈니스 흐름 전체 검증 - */ -@SpringBootTest -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class UserFacadeIntegrationTest { - - @Autowired - private UserFacade userFacade; - - @Autowired - private AuthFacade authFacade; - - @Autowired - private UserService userService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("getUser 메서드는") - class GetMyInfo { - - @Test - void 인증된_사용자_정보를_반환한다() { - // arrange - String loginId = "nahyeon"; - String password = "Hx7!mK2@"; - authFacade.createUser(loginId, password, "홍길동", "1994-11-15", "nahyeon@example.com"); - User user = userService.authenticateUser(loginId, password); - - // act - UserInfo result = userFacade.getUser(user); - - // assert - assertAll( - () -> assertThat(result.loginId()).isEqualTo("nahyeon"), - () -> assertThat(result.name()).isEqualTo("홍길동"), - () -> assertThat(result.maskedName()).isEqualTo("홍길*"), - () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), - () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") - ); - } - } -} From 80fb46fcd4b3f18b373902bcbf1d3bf03fb82456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:57:32 +0900 Subject: [PATCH 40/44] =?UTF-8?q?fix:=20Email=20VO=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=84=EC=86=8C=ED=99=94=20=EB=B0=8F=20UserName?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=EA=B2=80=EC=A6=9D=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email 정규식을 기본 이메일 형식으로 간소화 - UserName validate()에 길이 검증 로직 추가 (상수만 정의되어 있던 버그 수정) --- .../com/loopers/domain/user/vo/Email.java | 17 +-- .../com/loopers/domain/user/vo/UserName.java | 2 +- .../com/loopers/domain/user/EmailTest.java | 106 +++--------------- 3 files changed, 18 insertions(+), 107 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java index 5e5b5e48..ba292170 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java @@ -11,18 +11,15 @@ * 이메일 Value Object * * 검증 규칙: - * - RFC 5322 표준 이메일 형식 + * - 기본 이메일 형식 (local@domain) * - 최대 255자 - * - 한글/비ASCII 문자 불허 - * - 공백 불허 - * - 로컬 파트 연속 점 불허 */ @Embeddable public class Email { private static final int MAX_LENGTH = 255; private static final Pattern EMAIL_PATTERN = Pattern.compile( - "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$" + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" ); @Column(name = "email") @@ -53,15 +50,5 @@ private void validate(String value) { throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일 형식이 올바르지 않습니다."); } - - validateNoConsecutiveDots(value); - } - - private void validateNoConsecutiveDots(String value) { - String localPart = value.substring(0, value.indexOf('@')); - if (localPart.contains("..")) { - throw new CoreException(UserErrorType.INVALID_EMAIL, - "이메일 로컬 파트에 연속된 점(.)을 사용할 수 없습니다."); - } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java index cbd8d4dc..52ef2c45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java @@ -50,7 +50,7 @@ private void validate(String value) { if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { throw new CoreException(UserErrorType.INVALID_NAME, - "이름은 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + "이름은 " + MIN_LENGTH + "자 이상 " + MAX_LENGTH + "자 이하여야 합니다."); } if (!PATTERN.matcher(value).matches()) { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 8f373bac..5227fa6a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -12,16 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -/** - * Email Value Object 단위 테스트 - * - * 검증 규칙: - * - RFC 5322 표준 이메일 형식 - * - 최대 255자 - * - 한글 불허 - * - 공백 불허 - * - 연속 점 불허 (로컬 파트) - */ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) public class EmailTest { @@ -29,136 +19,70 @@ public class EmailTest { @Nested class Create { - // ========== 정상 케이스 ========== - @Test void 유효한_이메일이면_정상적으로_생성된다() { - // arrange - String value = "nahyeon@example.com"; - - // act - Email email = new Email(value); - - // assert - assertThat(email.getValue()).isEqualTo(value); + Email email = new Email("nahyeon@example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon@example.com"); } @Test void 서브도메인이_있으면_정상적으로_생성된다() { - // arrange - String value = "nahyeon@mail.example.com"; - - // act - Email email = new Email(value); - - // assert - assertThat(email.getValue()).isEqualTo(value); + Email email = new Email("nahyeon@mail.example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon@mail.example.com"); } @Test void 플러스_기호가_포함되면_정상적으로_생성된다() { - // arrange - String value = "nahyeon+tag@example.com"; - - // act - Email email = new Email(value); - - // assert - assertThat(email.getValue()).isEqualTo(value); + Email email = new Email("nahyeon+tag@example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon+tag@example.com"); } - // ========== 엣지 케이스 ========== - @Test void null이면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email(null); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email(null)); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 빈_문자열이면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email(""); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 골뱅이가_없으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("nahyeonexample.com"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeonexample.com")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 도메인이_없으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("nahyeon@"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeon@")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 로컬_파트가_없으면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("@example.com"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); - } - - @Test - void 연속_점이_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("nahyeon..lim@example.com"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("@example.com")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 공백이_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("nahyeon lim@example.com"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeon lim@example.com")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 길이가_256자를_초과하면_예외가_발생한다() { - // arrange - String value = "a".repeat(250) + "@b.com"; // 256자 - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email(value); - }); - + String value = "a".repeat(250) + "@b.com"; + CoreException exception = assertThrows(CoreException.class, () -> new Email(value)); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } @Test void 한글이_포함되면_예외가_발생한다() { - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - new Email("홍길동@example.com"); - }); - + CoreException exception = assertThrows(CoreException.class, () -> new Email("홍길동@example.com")); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); } } From ffdfbaed4d22e45d640276b3345750e4f41bed57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 20:58:19 +0900 Subject: [PATCH 41/44] =?UTF-8?q?fix:=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC=20NPE=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/UserService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 3873b70e..d32cf880 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,7 +5,6 @@ import com.loopers.domain.user.vo.LoginId; import com.loopers.domain.user.vo.Password; import com.loopers.domain.user.vo.UserName; -import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.UserErrorType; import org.springframework.stereotype.Component; @@ -99,6 +98,9 @@ public void updateUserPassword(User user, String currentRawPassword, String newR /** 비밀번호에 생년월일(YYYYMMDD, YYMMDD, MMDD) 포함 금지 */ private void validatePasswordNotContainsBirthDate(String rawPassword, LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); String[] patterns = { yyyymmdd, yyyymmdd.substring(2), yyyymmdd.substring(4) }; for (String pattern : patterns) { From faed7b9e5f2da2bad5b58a1be7bcf9b302a9c931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 21:03:47 +0900 Subject: [PATCH 42/44] =?UTF-8?q?test:=20user=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=8B=A8=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/user/UserServiceTest.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index e8dbf9ed..ac1c103c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -56,7 +57,7 @@ class Signup { // act User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); - // assert + // assert - 데이터 검증 assertAll( () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), () -> assertThat(user.getPassword()).isEqualTo("$2a$10$encodedHash"), @@ -64,6 +65,11 @@ class Signup { () -> assertThat(user.getBirthDate().getValue()).isEqualTo(java.time.LocalDate.of(1994, 11, 15)), () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") ); + + // assert - 행위 검증 (test double) + verify(userRepository).existsByLoginId("nahyeon"); + verify(passwordEncryptor).encode("Hx7!mK2@"); + verify(userRepository).save(any(User.class)); } @Test @@ -77,6 +83,9 @@ class Signup { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + + // 중복 시 save가 호출되지 않아야 함 + verify(userRepository, never()).save(any(User.class)); } @Test @@ -111,8 +120,12 @@ class Authenticate { // act User result = userService.authenticateUser("nahyeon", "Hx7!mK2@"); - // assert + // assert - 데이터 검증 assertThat(result.getLoginId().getValue()).isEqualTo("nahyeon"); + + // assert - 행위 검증 + verify(userRepository).findByLoginId("nahyeon"); + verify(passwordEncryptor).matches("Hx7!mK2@", "$2a$10$hash"); } @Test From 46ab63e11ef0b9430eafb8892309a57e918bd6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 7 Feb 2026 22:51:44 +0900 Subject: [PATCH 43/44] =?UTF-8?q?refactor:=20auth=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20user=EB=A1=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20V1=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/auth/AuthFacade.java | 35 --- .../loopers/application/user/UserInfo.java | 3 + .../com/loopers/domain/user/UserService.java | 7 +- .../interfaces/api/auth/AuthV1ApiSpec.java | 16 -- .../interfaces/api/auth/AuthV1Controller.java | 46 ---- .../interfaces/api/auth/AuthV1Response.java | 20 -- .../interfaces/api/user/UserApiSpec.java | 19 ++ .../interfaces/api/user/UserController.java | 47 ++++ .../UserRequest.java} | 6 +- .../interfaces/api/user/UserResponse.java | 31 +++ .../interfaces/api/user/UserV1ApiSpec.java | 13 -- .../interfaces/api/user/UserV1Controller.java | 21 -- .../interfaces/api/user/UserV1Response.java | 21 -- .../auth/AuthFacadeIntegrationTest.java | 211 ------------------ .../user/UserServiceIntegrationTest.java | 151 +++++++++++++ ...hV1ApiE2ETest.java => UserApiE2ETest.java} | 81 +++++-- .../interfaces/api/UserV1ApiE2ETest.java | 93 -------- http/commerce-api/user-v1.http | 4 - http/commerce-api/{auth-v1.http => user.http} | 13 +- 19 files changed, 330 insertions(+), 508 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{auth/AuthV1Request.java => user/UserRequest.java} (88%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{AuthV1ApiE2ETest.java => UserApiE2ETest.java} (62%) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java delete mode 100644 http/commerce-api/user-v1.http rename http/commerce-api/{auth-v1.http => user.http} (59%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java deleted file mode 100644 index 1c98a5be..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/auth/AuthFacade.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.loopers.application.auth; - -import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import org.springframework.stereotype.Component; - -/** - * 인증 퍼사드 (Application Layer) - * - * 회원가입, 비밀번호 변경 등 인증 관련 유스케이스를 오케스트레이션한다. - */ -@Component -public class AuthFacade { - private final UserService userService; - - public AuthFacade(UserService userService) { - this.userService = userService; - } - - public UserInfo createUser(String loginId, String password, String name, String birthDate, String email) { - User user = this.userService.createUser(loginId, password, name, birthDate, email); - return UserInfo.from(user); - } - - /** - * 비밀번호 변경 - * - * 인증은 AuthUserResolver(@AuthUser)에서 완료된 상태이며, - * 본문의 currentPassword로 2차 확인 후 변경한다. - */ - public void updateUserPassword(User user, String currentPassword, String newPassword) { - this.userService.updateUserPassword(user, currentPassword, newPassword); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 69f82ca5..5f562c8f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -18,6 +18,9 @@ public record UserInfo( String email ) { public static UserInfo from(User user) { + if (user == null) { + throw new IllegalArgumentException("User는 null일 수 없습니다."); + } return new UserInfo( user.getLoginId().getValue(), user.getName().getValue(), diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index d32cf880..e9e48357 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -85,7 +85,12 @@ public void updateUserPassword(User user, String currentRawPassword, String newR } Password.of(newRawPassword); - validatePasswordNotContainsBirthDate(newRawPassword, user.getBirthDate().getValue()); + + BirthDate birthDate = user.getBirthDate(); + if (birthDate == null) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } + validatePasswordNotContainsBirthDate(newRawPassword, birthDate.getValue()); if (this.passwordEncryptor.matches(newRawPassword, user.getPassword())) { throw new CoreException(UserErrorType.SAME_PASSWORD); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java deleted file mode 100644 index c321e3a1..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1ApiSpec.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.loopers.interfaces.api.auth; - -import com.loopers.domain.user.User; -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Auth V1 API", description = "인증 관련 API") -public interface AuthV1ApiSpec { - - @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") - ApiResponse createUser(AuthV1Request.SignupRequest request); - - @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") - ApiResponse updateUserPassword(User user, AuthV1Request.ChangePasswordRequest request); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java deleted file mode 100644 index e0b5ef71..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Controller.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.interfaces.api.auth; - -import com.loopers.application.auth.AuthFacade; -import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.User; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.auth.AuthUser; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -/** - * 인증 API 컨트롤러 (V1) - * - * 회원가입: 인증 불필요 (RequestBody만 사용) - * 비밀번호 변경: @AuthUser로 헤더 인증 + RequestBody의 currentPassword로 2차 확인 - */ -@RestController -@RequestMapping("/api/v1/auth") -public class AuthV1Controller implements AuthV1ApiSpec { - - private final AuthFacade authFacade; - - public AuthV1Controller(AuthFacade authFacade) { - this.authFacade = authFacade; - } - - @PostMapping("/signup") - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse createUser(@RequestBody AuthV1Request.SignupRequest request) { - UserInfo info = this.authFacade.createUser( - request.loginId(), request.password(), request.name(), request.birthDate(), request.email() - ); - return ApiResponse.success(AuthV1Response.SignupResponse.from(info)); - } - - @PutMapping("/password") - @Override - public ApiResponse updateUserPassword( - @AuthUser User user, - @RequestBody AuthV1Request.ChangePasswordRequest request - ) { - this.authFacade.updateUserPassword(user, request.currentPassword(), request.newPassword()); - return ApiResponse.success(null); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java deleted file mode 100644 index cd838307..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Response.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.interfaces.api.auth; - -import com.loopers.application.user.UserInfo; - -import java.time.LocalDate; - -/** 인증 API V1 응답 DTO */ -public class AuthV1Response { - - public record SignupResponse( - String loginId, - String name, - LocalDate birthDate, - String email - ) { - public static SignupResponse from(UserInfo info) { - return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java new file mode 100644 index 00000000..4126a10e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User API", description = "사용자 관련 API") +public interface UserApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse createUser(UserRequest.SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") + ApiResponse getUser(User user); + + @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") + ApiResponse updateUserPassword(User user, UserRequest.ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java new file mode 100644 index 00000000..187af9cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +/** + * 사용자 API 컨트롤러 + */ +@RestController +@RequestMapping("/api/v1/users") +public class UserController implements UserApiSpec { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createUser(@RequestBody UserRequest.SignupRequest request) { + User user = this.userService.createUser( + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + ); + UserInfo info = UserInfo.from(user); + return ApiResponse.success(UserResponse.SignupResponse.from(info)); + } + @GetMapping("/me") + @Override + public ApiResponse getUser(@AuthUser User user) { + return ApiResponse.success(UserResponse.UserDetailResponse.from(UserInfo.from(user))); + } + @PutMapping("/password") + @Override + public ApiResponse updateUserPassword( + @AuthUser User user, + @RequestBody UserRequest.ChangePasswordRequest request + ) { + this.userService.updateUserPassword(user, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java similarity index 88% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java index b270245b..05bc3145 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthV1Request.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java @@ -1,7 +1,7 @@ -package com.loopers.interfaces.api.auth; +package com.loopers.interfaces.api.user; -/** 인증 API V1 요청 DTO */ -public class AuthV1Request { +/** 사용자 API 요청 DTO */ +public class UserRequest { public record SignupRequest( String loginId, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java new file mode 100644 index 00000000..0cb032d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +/** 사용자 API 응답 DTO */ +public class UserResponse { + public record SignupResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static SignupResponse from(UserInfo info) { + return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + /** 이름은 개인정보 보호를 위해 maskedName으로 반환한다 */ + public record UserDetailResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserDetailResponse from(UserInfo info) { + return new UserDetailResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java deleted file mode 100644 index 403f71f8..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.domain.user.User; -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "User V1 API", description = "사용자 정보 관련 API") -public interface UserV1ApiSpec { - - @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") - ApiResponse getUser(User user); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java deleted file mode 100644 index 90c91aa3..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.User; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.auth.AuthUser; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** 사용자 API 컨트롤러 (V1) */ -@RestController -@RequestMapping("/api/v1/users") -public class UserV1Controller implements UserV1ApiSpec { - - @GetMapping("/me") - @Override - public ApiResponse getUser(@AuthUser User user) { - return ApiResponse.success(UserV1Response.UserResponse.from(UserInfo.from(user))); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java deleted file mode 100644 index c309c38a..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Response.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserInfo; - -import java.time.LocalDate; - -/** 사용자 API V1 응답 DTO */ -public class UserV1Response { - - /** 이름은 개인정보 보호를 위해 maskedName으로 반환한다 */ - public record UserResponse( - String loginId, - String name, - LocalDate birthDate, - String email - ) { - public static UserResponse from(UserInfo info) { - return new UserResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java deleted file mode 100644 index ed2dc718..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/auth/AuthFacadeIntegrationTest.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.loopers.application.auth; - -import com.loopers.application.user.UserInfo; -import com.loopers.domain.user.PasswordEncryptor; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.UserErrorType; -import com.loopers.utils.DatabaseCleanUp; -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; -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 java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * [Application Layer - AuthFacade 통합 테스트] - * - * AuthFacade의 비즈니스 흐름을 검증하는 통합 테스트. - * Spring Context를 로드하고 실제 DB를 사용하여 오케스트레이션 로직을 검증한다. - * - * 테스트 범위: - * - AuthFacade → UserService → UserRepository → DB - * - 실제 비즈니스 흐름 전체 검증 - */ -@SpringBootTest -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class AuthFacadeIntegrationTest { - - @Autowired - private AuthFacade authFacade; - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private PasswordEncryptor passwordEncryptor; - - @Autowired - private EntityManager entityManager; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("createUser 메서드는") - class Signup { - - @Test - void 유효한_정보로_가입하면_UserInfo를_반환한다() { - // arrange - String loginId = "nahyeon"; - String password = "Hx7!mK2@"; - String name = "홍길동"; - String birthDate = "1994-11-15"; - String email = "nahyeon@example.com"; - - // act - UserInfo result = authFacade.createUser(loginId, password, name, birthDate, email); - - // assert - assertAll( - () -> assertThat(result.loginId()).isEqualTo("nahyeon"), - () -> assertThat(result.name()).isEqualTo("홍길동"), - () -> assertThat(result.maskedName()).isEqualTo("홍길*"), - () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(1994, 11, 15)), - () -> assertThat(result.email()).isEqualTo("nahyeon@example.com") - ); - } - - @Test - void 가입_후_DB에_사용자가_저장된다() { - // arrange - String loginId = "testuser"; - String password = "Hx7!mK2@"; - String name = "테스트"; - String birthDate = "1990-01-01"; - String email = "test@example.com"; - - // act - authFacade.createUser(loginId, password, name, birthDate, email); - - // assert - assertThat(userRepository.existsByLoginId(loginId)).isTrue(); - } - - @Test - void 중복된_로그인ID로_가입하면_예외가_발생한다() { - // arrange - String loginId = "duplicate"; - authFacade.createUser(loginId, "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); - } - - @Test - void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { - // arrange - String loginId = "nahyeon"; - String password = "X19940115!"; // contains birthDate - String name = "홍길동"; - String birthDate = "1994-01-15"; - String email = "nahyeon@example.com"; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.createUser(loginId, password, name, birthDate, email); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); - } - } - - @Nested - @DisplayName("updateUserPassword 메서드는") - class ChangePassword { - - @Test - void 유효한_요청이면_비밀번호가_변경된다() { - // arrange - String loginId = "nahyeon"; - String currentPassword = "Hx7!mK2@"; - String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); - User user = userService.authenticateUser(loginId, currentPassword); - - // act - authFacade.updateUserPassword(user, currentPassword, newPassword); - - // assert - 새 비밀번호로 인증 성공 확인 - User reloaded = userRepository.findByLoginId(loginId).orElseThrow(); - assertThat(passwordEncryptor.matches(newPassword, reloaded.getPassword())).isTrue(); - } - - @Test - void 변경된_비밀번호로_재인증에_성공하고_이전_비밀번호는_실패한다() { - // arrange - String loginId = "nahyeon"; - String oldPassword = "Hx7!mK2@"; - String newPassword = "Nw8@pL3#"; - authFacade.createUser(loginId, oldPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); - User user = userService.authenticateUser(loginId, oldPassword); - - // act - authFacade.updateUserPassword(user, oldPassword, newPassword); - - // assert - DB에서 재조회하여 새 비밀번호로 인증 성공 확인 - User reloadedUser = userRepository.findByLoginId(loginId).orElseThrow(); - assertAll( - () -> assertThat(passwordEncryptor.matches(newPassword, reloadedUser.getPassword())).isTrue(), - () -> assertThat(passwordEncryptor.matches(oldPassword, reloadedUser.getPassword())).isFalse() - ); - } - - @Test - void 현재_비밀번호가_틀리면_예외가_발생한다() { - // arrange - String loginId = "nahyeon"; - String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); - User user = userService.authenticateUser(loginId, currentPassword); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.updateUserPassword(user, "wrongPw1!", "Nw8@pL3#"); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); - } - - @Test - void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { - // arrange - String loginId = "nahyeon"; - String currentPassword = "Hx7!mK2@"; - authFacade.createUser(loginId, currentPassword, "홍길동", "1994-11-15", "nahyeon@example.com"); - User user = userService.authenticateUser(loginId, currentPassword); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - authFacade.updateUserPassword(user, currentPassword, currentPassword); - }); - - assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..a73dbf9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,151 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * UserService 통합 테스트 + * + * 실제 DB(Testcontainers)를 사용하여 UserService의 비즈니스 흐름을 검증한다. + * + * 테스트 범위: + * - UserService → UserRepository → DB + * - 실제 비밀번호 암호화 + DB 영속화 + */ +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncryptor passwordEncryptor; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("회원가입 시,") + class Signup { + + @Test + void 유효한_정보면_DB에_저장된다() { + // act + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert + assertAll( + () -> assertThat(user.getId()).isNotNull(), + () -> assertThat(userRepository.existsByLoginId("nahyeon")).isTrue() + ); + } + + @Test + void 비밀번호가_BCrypt로_암호화되어_저장된다() { + // act + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert + User saved = userRepository.findByLoginId("nahyeon").orElseThrow(); + assertAll( + () -> assertThat(saved.getPassword()).startsWith("$2a$"), + () -> assertThat(passwordEncryptor.matches("Hx7!mK2@", saved.getPassword())).isTrue() + ); + } + + @Test + void 중복된_로그인ID면_예외가_발생한다() { + // arrange + userService.createUser("duplicate", "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("duplicate", "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + } + + @Test + void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "X19940115!", "홍길동", "1994-01-15", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + @Nested + @DisplayName("비밀번호 변경 시,") + class ChangePassword { + + @Test + void 유효한_요청이면_DB에_새_비밀번호가_반영된다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act + userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); + + // assert + User reloaded = userRepository.findByLoginId("nahyeon").orElseThrow(); + assertAll( + () -> assertThat(passwordEncryptor.matches("Nw8@pL3#", reloaded.getPassword())).isTrue(), + () -> assertThat(passwordEncryptor.matches("Hx7!mK2@", reloaded.getPassword())).isFalse() + ); + } + + @Test + void 현재_비밀번호가_틀리면_예외가_발생한다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "wrongPw1!", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + } + + @Test + void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java similarity index 62% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java index e6835e84..8658517a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AuthV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api; -import com.loopers.interfaces.api.auth.AuthV1Request; -import com.loopers.interfaces.api.auth.AuthV1Response; +import com.loopers.interfaces.api.user.UserRequest; +import com.loopers.interfaces.api.user.UserResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -12,6 +12,7 @@ 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.*; import static org.assertj.core.api.Assertions.assertThat; @@ -19,11 +20,11 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class AuthV1ApiE2ETest { +class UserApiE2ETest { - private static final String SIGNUP_URL = "/api/v1/auth/signup"; - private static final String CHANGE_PW_URL = "/api/v1/auth/password"; + private static final String USERS_URL = "/api/v1/users"; private static final String ME_URL = "/api/v1/users/me"; + private static final String PASSWORD_URL = "/api/v1/users/password"; @Autowired private TestRestTemplate testRestTemplate; @@ -36,12 +37,12 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private AuthV1Request.SignupRequest validSignupRequest() { - return new AuthV1Request.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + private UserRequest.SignupRequest validSignupRequest() { + return new UserRequest.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); } - private ResponseEntity signup(AuthV1Request.SignupRequest request) { - return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); + private ResponseEntity signup(UserRequest.SignupRequest request) { + return testRestTemplate.postForEntity(USERS_URL, request, ApiResponse.class); } private HttpHeaders authHeaders(String loginId, String password) { @@ -53,7 +54,7 @@ private HttpHeaders authHeaders(String loginId, String password) { // ========== 회원가입 ========== - @DisplayName("POST /api/v1/auth/signup") + @DisplayName("POST /api/v1/users") @Nested class Signup { @@ -81,7 +82,7 @@ class Signup { @Test void 잘못된_비밀번호_형식이면_400_Bad_Request_응답을_받는다() { // arrange - AuthV1Request.SignupRequest request = new AuthV1Request.SignupRequest( + UserRequest.SignupRequest request = new UserRequest.SignupRequest( "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" ); @@ -95,7 +96,7 @@ class Signup { @Test void 비밀번호에_생년월일이_포함되면_400_Bad_Request_응답을_받는다() { // arrange - AuthV1Request.SignupRequest request = new AuthV1Request.SignupRequest( + UserRequest.SignupRequest request = new UserRequest.SignupRequest( "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" ); @@ -107,9 +108,49 @@ class Signup { } } + // ========== 내 정보 조회 ========== + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMyInfo { + + @Test + void 유효한_인증_정보로_조회하면_200_OK와_마스킹된_이름을_반환한다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + + // act + ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), type); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("nahyeon") + ); + } + + @Test + void 잘못된_비밀번호로_조회하면_401_Unauthorized_응답을_받는다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!"); + + // act + ResponseEntity response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + // ========== 비밀번호 변경 ========== - @DisplayName("PUT /api/v1/auth/password") + @DisplayName("PUT /api/v1/users/password") @Nested class ChangePassword { @@ -119,11 +160,11 @@ class ChangePassword { signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); // act ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -131,12 +172,12 @@ class ChangePassword { @Test void 변경_후_새_비밀번호로_인증되고_이전_비밀번호로는_실패한다() { - // arrange - 회원가입 + 비밀번호 변경 + // arrange signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); - testRestTemplate.exchange(CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + testRestTemplate.exchange(PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // act - 새 비밀번호로 조회 HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); @@ -161,11 +202,11 @@ class ChangePassword { signup(validSignupRequest()); HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); headers.setContentType(MediaType.APPLICATION_JSON); - AuthV1Request.ChangePasswordRequest body = new AuthV1Request.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); // act ResponseEntity response = testRestTemplate.exchange( - CHANGE_PW_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java deleted file mode 100644 index 4a23ec5f..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.interfaces.api.auth.AuthV1Request; -import com.loopers.interfaces.api.user.UserV1Response; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; -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.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class UserV1ApiE2ETest { - - private static final String SIGNUP_URL = "/api/v1/auth/signup"; - private static final String ME_URL = "/api/v1/users/me"; - - @Autowired - private TestRestTemplate testRestTemplate; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private AuthV1Request.SignupRequest validSignupRequest() { - return new AuthV1Request.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); - } - - private ResponseEntity signup(AuthV1Request.SignupRequest request) { - return testRestTemplate.postForEntity(SIGNUP_URL, request, ApiResponse.class); - } - - private HttpHeaders authHeaders(String loginId, String password) { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Loopers-LoginId", loginId); - headers.set("X-Loopers-LoginPw", password); - return headers; - } - - // ========== 내 정보 조회 ========== - - @DisplayName("GET /api/v1/users/me") - @Nested - class GetMyInfo { - - @Test - void 유효한_인증_정보로_조회하면_200_OK와_마스킹된_이름을_반환한다() { - // arrange - signup(validSignupRequest()); - HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); - - // act - ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), type); - - // assert - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), - () -> assertThat(response.getBody().data().loginId()).isEqualTo("nahyeon") - ); - } - - @Test - void 잘못된_비밀번호로_조회하면_401_Unauthorized_응답을_받는다() { - // arrange - signup(validSignupRequest()); - HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!"); - - // act - ResponseEntity response = - testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), ApiResponse.class); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - } -} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http deleted file mode 100644 index 8827c641..00000000 --- a/http/commerce-api/user-v1.http +++ /dev/null @@ -1,4 +0,0 @@ -### 내 정보 조회 -GET {{commerce-api}}/api/v1/users/me -X-Loopers-LoginId: {{loginId}} -X-Loopers-LoginPw: {{loginPw}} \ No newline at end of file diff --git a/http/commerce-api/auth-v1.http b/http/commerce-api/user.http similarity index 59% rename from http/commerce-api/auth-v1.http rename to http/commerce-api/user.http index 72893443..b0286084 100644 --- a/http/commerce-api/auth-v1.http +++ b/http/commerce-api/user.http @@ -1,5 +1,5 @@ ### 회원가입 -POST {{commerce-api}}/api/v1/auth/signup +POST {{commerce-api}}/api/v1/users Content-Type: application/json { @@ -10,13 +10,18 @@ Content-Type: application/json "email": "test@example.com" } +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}} + ### 비밀번호 변경 -PUT {{commerce-api}}/api/v1/auth/password +PUT {{commerce-api}}/api/v1/users/password Content-Type: application/json X-Loopers-LoginId: {{loginId}} X-Loopers-LoginPw: {{loginPw}} { "currentPassword": "{{loginPw}}", - "newPassword": "NewPass1234!@" -} \ No newline at end of file + "newPassword": "NewPass1892!@" +} From 292f902055c275e754ef38f029c492a936bd7ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sun, 8 Feb 2026 13:48:14 +0900 Subject: [PATCH 44/44] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=20=EB=B3=84=20?= =?UTF-8?q?=EC=A3=BC=EC=84=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/CommerceApiApplication.java | 2 +- .../java/com/loopers/config/WebMvcConfig.java | 6 ++ .../loopers/domain/user/UserRepository.java | 2 +- .../com/loopers/domain/user/UserService.java | 9 +++ .../user/UserJpaRepository.java | 7 +++ .../interfaces/api/user/UserApiSpec.java | 6 ++ .../interfaces/api/user/UserController.java | 3 + .../com/loopers/support/auth/AuthUser.java | 6 ++ .../support/error/CommonErrorType.java | 6 ++ .../loopers/support/error/UserErrorType.java | 9 +++ .../loopers/domain/user/UserServiceTest.java | 62 +++++++++++++++++-- 11 files changed, 112 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51b..9c056332 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -12,7 +12,7 @@ public class CommerceApiApplication { @PostConstruct public void started() { - // set timezone + // JVM 기본 타임존을 Asia/Seoul로 설정하여 DB 저장, 로그 출력 시 시간대 일관성을 보장한다. TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index ef231cd6..cee2a341 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -7,6 +7,12 @@ import java.util.List; +/** + * Spring MVC 설정 + * + * {@link AuthUserResolver}를 ArgumentResolver로 등록하여 + * {@code @AuthUser} 어노테이션 기반의 인증된 사용자 주입을 활성화한다. + */ @Configuration public class WebMvcConfig implements WebMvcConfigurer { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 21f54eb0..a27484ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -3,7 +3,7 @@ import java.util.Optional; /** - * 사용자 리포지토리 JPA (Domain Layer) + * 사용자 리포지토리 포트 (Domain Layer) * * 도메인이 인프라(JPA)에 의존하지 않도록 추상화한 인터페이스. * 실제 구현은 Infrastructure 계층의 UserRepositoryImpl이 담당한다. diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index e9e48357..5f620386 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -29,6 +29,9 @@ public UserService(UserRepository userRepository, PasswordEncryptor passwordEncr this.passwordEncryptor = passwordEncryptor; } + /** + * 회원가입: VO 생성(형식 검증) → 비밀번호-생년월일 교차검증 → 로그인 ID 중복 확인 → 비밀번호 암호화 → 저장 + */ @Transactional public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { LoginId loginId = new LoginId(rawLoginId); @@ -49,6 +52,9 @@ public User createUser(String rawLoginId, String rawPassword, String rawName, St return this.userRepository.save(user); } + /** + * 로그인 인증: 필수값 검증 → 사용자 조회 → 비밀번호 일치 확인 + */ @Transactional(readOnly = true) public User authenticateUser(String rawLoginId, String rawPassword) { if (rawLoginId == null || rawLoginId.isBlank()) { @@ -68,6 +74,9 @@ public User authenticateUser(String rawLoginId, String rawPassword) { return user; } + /** + * 비밀번호 변경: 필수값 검증 → 현재 비밀번호 확인 → 새 비밀번호 정책 검증 → 생년월일 교차검증 → 기존 비밀번호 동일 여부 확인 → 암호화 후 저장 + */ @Transactional public void updateUserPassword(User user, String currentRawPassword, String newRawPassword) { if (user == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index f7745368..3149f6c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -5,6 +5,13 @@ import java.util.Optional; +/** + * 사용자 JPA 리포지토리 + * + * Spring Data JPA가 제공하는 기본 CRUD와 쿼리 메서드를 정의한다. + * 도메인 계층의 {@link com.loopers.domain.user.UserRepository} 포트 구현체인 + * {@link UserRepositoryImpl}이 이 인터페이스에 위임하여 실제 DB 접근을 수행한다. + */ public interface UserJpaRepository extends JpaRepository { Optional findByLoginIdValue(String loginId); boolean existsByLoginIdValue(String loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java index 4126a10e..41d70a7f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java @@ -5,6 +5,12 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +/** + * 사용자 API 스펙 인터페이스 + * + * Swagger(OpenAPI) 문서화 어노테이션과 Controller 메서드 시그니처를 분리하여 + * Controller 구현체의 가독성을 높이고, API 계약을 명시적으로 정의한다. + */ @Tag(name = "User API", description = "사용자 관련 API") public interface UserApiSpec { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java index 187af9cf..896eb66f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -20,6 +20,7 @@ public class UserController implements UserApiSpec { public UserController(UserService userService) { this.userService = userService; } + @PostMapping @ResponseStatus(HttpStatus.CREATED) @Override @@ -30,11 +31,13 @@ public ApiResponse createUser(@RequestBody UserRequ UserInfo info = UserInfo.from(user); return ApiResponse.success(UserResponse.SignupResponse.from(info)); } + @GetMapping("/me") @Override public ApiResponse getUser(@AuthUser User user) { return ApiResponse.success(UserResponse.UserDetailResponse.from(UserInfo.from(user))); } + @PutMapping("/password") @Override public ApiResponse updateUserPassword( diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java index a66e38d4..daf11b98 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java @@ -5,6 +5,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * 인증된 사용자 주입 어노테이션 + * + * Controller 메서드 파라미터에 선언하면 {@link AuthUserResolver}가 + * 요청 헤더의 인증 정보를 기반으로 User 객체를 주입한다. + */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface AuthUser { diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java index 25b9dfad..cbcac1d9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -2,6 +2,12 @@ import org.springframework.http.HttpStatus; +/** + * 공통 에러 타입 + * + * 특정 도메인에 속하지 않는 범용 HTTP 에러를 정의한다. + * 도메인별 에러 타입(예: {@link UserErrorType})과 분리하여 관리한다. + */ public enum CommonErrorType implements ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java index 70610af0..a9186ea4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -2,6 +2,15 @@ import org.springframework.http.HttpStatus; +/** + * 사용자 도메인 에러 타입 + * + * 사용자 관련 비즈니스 에러를 HTTP 상태 코드별로 그룹핑하여 정의한다. + * - 400: 입력값 형식/정책 위반 (로그인 ID, 비밀번호, 이름, 생년월일, 이메일) + * - 401: 인증 실패, 비밀번호 불일치 + * - 404: 사용자 미존재 + * - 409: 중복 리소스 (로그인 ID) + */ public enum UserErrorType implements ErrorType { // 400 Bad Request INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "로그인 ID 형식이 올바르지 않습니다."), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index ac1c103c..39834c27 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -99,6 +99,38 @@ class Signup { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + + // 검증 실패 시 암호화·저장이 호출되지 않아야 함 + verify(passwordEncryptor, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 비밀번호에_생년월일_YYMMDD가_포함되면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "900325" (YYMMDD) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "Abc900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 비밀번호에_생년월일_MMDD가_포함되면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "0325" (MMDD) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "Abc!0325xY", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); } } @@ -139,6 +171,9 @@ class Authenticate { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + + // 유저 조회 실패 시 비밀번호 비교가 호출되지 않아야 함 + verify(passwordEncryptor, never()).matches(anyString(), anyString()); } @Test @@ -168,6 +203,15 @@ class Authenticate { assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); } + @Test + void 비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("nahyeon", " "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + @Test void 비밀번호가_불일치하면_예외가_발생한다() { // arrange @@ -208,9 +252,13 @@ class ChangePassword { // act userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); - // assert - 영속화 호출 검증 + 변경된 비밀번호 검증 - verify(userRepository).save(user); + // assert - 데이터 검증 assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); + + // assert - 행위 검증 (test double) + verify(passwordEncryptor).matches("Hx7!mK2@", "$2a$10$oldHash"); + verify(passwordEncryptor).encode("Nw8@pL3#"); + verify(userRepository).save(user); } @Test @@ -220,6 +268,7 @@ class ChangePassword { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.USER_NOT_FOUND); + verify(userRepository, never()).save(any(User.class)); } @Test @@ -235,6 +284,7 @@ class ChangePassword { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + verify(userRepository, never()).save(any(User.class)); } @Test @@ -250,6 +300,7 @@ class ChangePassword { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + verify(userRepository, never()).save(any(User.class)); } @Test @@ -268,6 +319,7 @@ class ChangePassword { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + verify(userRepository, never()).save(any(User.class)); } @Test @@ -286,11 +338,12 @@ class ChangePassword { }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + verify(userRepository, never()).save(any(User.class)); } @Test void 새_비밀번호에_생년월일이_포함되면_예외가_발생한다() { - // arrange - birthDate: 1990-03-25 (연속 동일 문자 없음) + // arrange - birthDate: 1990-03-25 User user = User.create( new LoginId("nahyeon"), "$2a$10$hash", new UserName("홍길동"), new BirthDate("1990-03-25"), @@ -298,12 +351,13 @@ class ChangePassword { ); when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); - // act & assert - newPassword contains "19900325" (birthDate) + // act & assert - newPassword contains "19900325" (YYYYMMDD) CoreException exception = assertThrows(CoreException.class, () -> { userService.updateUserPassword(user, "Hx7!mK2@", "X19900325!"); }); assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); } } }