From 7ae573bc2959bc7599039d0207fcfc6ea8c749c4 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 14:34:48 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20UserTermsAgreement=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=AC=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 --- .../terms/entity/UserTermsAgreement.java | 55 +++++++++++++++++++ .../UserTermsAgreementRepository.java | 7 +++ 2 files changed, 62 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java create mode 100644 src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java diff --git a/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java b/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java new file mode 100644 index 00000000..97b04704 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java @@ -0,0 +1,55 @@ +package com.dreamteam.alter.domain.terms.entity; + +import com.dreamteam.alter.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "user_terms_agreements") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class UserTermsAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "terms_id", nullable = false) + private Terms terms; + + @CreatedDate + @Column(name = "agreed_at", nullable = false, updatable = false) + private LocalDateTime agreedAt; + + public static UserTermsAgreement create(User user, Terms terms) { + return UserTermsAgreement.builder() + .user(user) + .terms(terms) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java new file mode 100644 index 00000000..784b1c8c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.terms.port.outbound; + +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserTermsAgreementRepository extends JpaRepository { +} From 93a0565a17bcd3b076b63a4ff011372f59cfa4f3 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 14:36:57 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B0=8F=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terms/persistence/TermsQueryRepositoryImpl.java | 11 +++++++++++ .../terms/port/outbound/TermsQueryRepository.java | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java index 8d5dc3d3..535ed4c1 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java @@ -91,6 +91,17 @@ public Optional findPublishedByTypeWithLock(TermsType type) { ); } + @Override + public List findAllRequiredPublished() { + return queryFactory + .selectFrom(terms) + .where( + terms.status.eq(TermsStatus.PUBLISHED), + terms.required.isTrue() + ) + .fetch(); + } + private BooleanExpression notDeleted() { return terms.status.ne(TermsStatus.DELETED); } diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java index 4b7cccdd..2d2b6fd8 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java @@ -18,4 +18,6 @@ public interface TermsQueryRepository { Optional findPublishedByType(TermsType type); Optional findPublishedByTypeWithLock(TermsType type); + + List findAllRequiredPublished(); } From 879f50a8190b34f7b2d8c8a65a76c78eb6ff4e84 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 14:46:13 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/CreateUserRequestDto.java | 6 +++ .../dto/CreateUserWithSocialRequestDto.java | 6 +++ .../service/TermsAgreementValidator.java | 37 +++++++++++++++++++ .../application/user/usecase/CreateUser.java | 10 ++++- .../user/usecase/CreateUserTx.java | 14 ++++++- .../user/usecase/CreateUserWithSocial.java | 9 ++++- .../user/usecase/CreateUserWithSocialTx.java | 14 ++++++- .../alter/common/exception/ErrorCode.java | 2 + .../usecase/CreateUserWithSocialTests.java | 25 +++++++++---- 9 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java index 430c4c60..980ae75b 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java @@ -10,6 +10,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + @Getter @NoArgsConstructor @AllArgsConstructor @@ -58,4 +60,8 @@ public class CreateUserRequestDto { @Schema(description = "야간 알림 수신 동의 여부", example = "false") private Boolean nightNotificationConsent; + @NotNull + @Schema(description = "동의한 약관 ID 목록", example = "[1, 2, 3]") + private List agreedTermsIds; + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java index 2fc34b26..7bd82a8a 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -13,6 +13,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + @Getter @NoArgsConstructor @AllArgsConstructor @@ -66,6 +68,10 @@ public class CreateUserWithSocialRequestDto { @Schema(description = "야간 알림 수신 동의 여부", example = "false") private Boolean nightNotificationConsent; + @NotNull + @Schema(description = "동의한 약관 ID 목록", example = "[1, 2, 3]") + private List agreedTermsIds; + @AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다") private boolean isWebPlatformValid() { if (platformType != PlatformType.WEB) return true; diff --git a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java new file mode 100644 index 00000000..00edddc2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java @@ -0,0 +1,37 @@ +package com.dreamteam.alter.application.terms.service; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component("termsAgreementValidator") +@RequiredArgsConstructor +public class TermsAgreementValidator { + + private final TermsQueryRepository termsQueryRepository; + + public List validateAndResolve(List agreedTermsIds) { + List requiredTermsList = termsQueryRepository.findAllRequiredPublished(); + + Set agreedSet = new HashSet<>(agreedTermsIds); + boolean allRequiredAgreed = requiredTermsList.stream() + .allMatch(t -> agreedSet.contains(t.getId())); + + if (!allRequiredAgreed) { + throw new CustomException(ErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + + return agreedTermsIds.stream() + .map(id -> termsQueryRepository.findById(id) + .orElseThrow(() -> new CustomException( + ErrorCode.TERMS_NOT_FOUND, "약관 ID: " + id))) + .toList(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java index dd641986..d85370bc 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java @@ -8,6 +8,8 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.PasswordValidator; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; +import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import lombok.RequiredArgsConstructor; @@ -16,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; +import java.util.List; @Service("createUser") @RequiredArgsConstructor @@ -26,6 +29,7 @@ public class CreateUser implements CreateUserUseCase { private final SignupSessionCacheRepository cacheRepository; private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; private final CreateUserTx createUserTx; + private final TermsAgreementValidator termsAgreementValidator; @Override public GenerateTokenResponseDto execute(CreateUserRequestDto request) { @@ -41,6 +45,9 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { // 중복 확인 validateDuplication(request, contact, sessionIdKey); + // 약관 동의 검증 + List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsIds()); + // 비밀번호 형식 검증 if (!PasswordValidator.isValid(request.getPassword())) { throw new CustomException(ErrorCode.INVALID_PASSWORD_FORMAT); @@ -53,7 +60,8 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { GenerateTokenResponseDto response = createUserTx.process( request, contact, verifiedEmail, request.getNotificationConsent(), - request.getNightNotificationConsent() + request.getNightNotificationConsent(), + agreedTerms ); // 회원가입 세션 삭제 diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java index de944f99..5aa46228 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java @@ -10,6 +10,9 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.notification.entity.NotificationConsent; import com.dreamteam.alter.domain.notification.port.outbound.NotificationConsentRepository; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; +import com.dreamteam.alter.domain.terms.port.outbound.UserTermsAgreementRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserRepository; import lombok.RequiredArgsConstructor; @@ -17,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class CreateUserTx { @@ -26,6 +31,7 @@ public class CreateUserTx { private final PasswordEncoder passwordEncoder; private final AuthLogRepository authLogRepository; private final NotificationConsentRepository notificationConsentRepository; + private final UserTermsAgreementRepository userTermsAgreementRepository; @Transactional public GenerateTokenResponseDto process( @@ -33,7 +39,8 @@ public GenerateTokenResponseDto process( String contact, String verifiedEmail, boolean notificationConsent, - boolean nightNotificationConsent + boolean nightNotificationConsent, + List agreedTerms ) { // 사용자 생성 User user = userRepository.save(User.create( @@ -48,6 +55,11 @@ public GenerateTokenResponseDto process( notificationConsentRepository.save(NotificationConsent.create(user, notificationConsent, nightNotificationConsent)); + List agreements = agreedTerms.stream() + .map(terms -> UserTermsAgreement.create(user, terms)) + .toList(); + userTermsAgreementRepository.saveAll(agreements); + Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index e62457d4..36f6a323 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -5,10 +5,12 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; import com.dreamteam.alter.common.constants.SignupSessionConstants; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.vo.SocialAuthRequest; +import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; @@ -29,6 +31,7 @@ public class CreateUserWithSocial implements CreateUserWithSocialUseCase { private final SocialAuthenticationManager socialAuthenticationManager; private final SignupSessionCacheRepository cacheRepository; private final CreateUserWithSocialTx createUserWithSocialTx; + private final TermsAgreementValidator termsAgreementValidator; @Override public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { @@ -44,6 +47,9 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) // 중복 확인 validateDuplication(request, contact, sessionIdKey); + // 약관 동의 검증 + List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsIds()); + // 소셜 인증 OauthToken oauthToken = request.getOauthToken() != null ? OauthToken.of(request.getOauthToken().getAccessToken(), request.getOauthToken().getRefreshToken()) @@ -76,7 +82,8 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) GenerateTokenResponseDto response = createUserWithSocialTx.process( contact, request, socialAuthInfo, request.getNotificationConsent(), - request.getNightNotificationConsent() + request.getNightNotificationConsent(), + agreedTerms ); // 회원가입 세션 삭제 diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java index 664caccc..90828892 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java @@ -11,6 +11,9 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.notification.entity.NotificationConsent; import com.dreamteam.alter.domain.notification.port.outbound.NotificationConsentRepository; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; +import com.dreamteam.alter.domain.terms.port.outbound.UserTermsAgreementRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.entity.UserSocial; import com.dreamteam.alter.domain.user.port.outbound.UserRepository; @@ -18,6 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class CreateUserWithSocialTx { @@ -26,6 +31,7 @@ public class CreateUserWithSocialTx { private final AuthService authService; private final AuthLogRepository authLogRepository; private final NotificationConsentRepository notificationConsentRepository; + private final UserTermsAgreementRepository userTermsAgreementRepository; @Transactional public GenerateTokenResponseDto process( @@ -33,7 +39,8 @@ public GenerateTokenResponseDto process( CreateUserWithSocialRequestDto request, SocialAuthInfo socialAuthInfo, boolean notificationConsent, - boolean nightNotificationConsent + boolean nightNotificationConsent, + List agreedTerms ) { // 사용자 생성 User user = userRepository.save(User.createWithSocial( @@ -47,6 +54,11 @@ public GenerateTokenResponseDto process( notificationConsentRepository.save(NotificationConsent.create(user, notificationConsent, nightNotificationConsent)); + List agreements = agreedTerms.stream() + .map(terms -> UserTermsAgreement.create(user, terms)) + .toList(); + userTermsAgreementRepository.saveAll(agreements); + // 소셜 계정 연동 UserSocial userSocial = UserSocial.create( user, diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 700f91d6..61a81a1b 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -24,6 +24,8 @@ public enum ErrorCode { SOCIAL_ACCOUNT_NOT_LINKED(400, "A015", "연동되지 않은 소셜 플랫폼입니다."), SOCIAL_UNLINK_NOT_ALLOWED(400, "A016", "비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다."), INVALID_CURRENT_PASSWORD(400, "A017", "현재 비밀번호가 올바르지 않습니다."), + REQUIRED_TERMS_NOT_AGREED(400, "A018", "필수 약관에 모두 동의해야 합니다."), + TERMS_NOT_FOUND(400, "A019", "존재하지 않는 약관입니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 43125d89..169f9ecf 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -5,6 +5,7 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.user.entity.User; @@ -22,6 +23,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +56,9 @@ class CreateUserWithSocialTests { @Mock private CreateUserWithSocialTx createUserWithSocialTx; + @Mock + private TermsAgreementValidator termsAgreementValidator; + @InjectMocks private CreateUserWithSocial createUserWithSocial; @@ -72,7 +77,8 @@ void setUp() { UserGender.GENDER_MALE, "19900101", true, - false + false, + List.of(1L, 2L) ); } @@ -101,7 +107,7 @@ void execute_signupSessionNotFound_throwsSignupSessionNotExist() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.SIGNUP_SESSION_NOT_EXIST)); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @Test @@ -118,7 +124,7 @@ void execute_nicknameDuplicated_throwsNicknameDuplicated() { .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); then(cacheRepository).should().deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @Test @@ -136,7 +142,7 @@ void execute_contactDuplicated_throwsUserContactDuplicated() { .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); then(cacheRepository).should().deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @Test @@ -146,6 +152,7 @@ void execute_socialIdAlreadyRegistered_throwsSocialIdDuplicated() { given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of()); SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); @@ -159,7 +166,7 @@ void execute_socialIdAlreadyRegistered_throwsSocialIdDuplicated() { .isEqualTo(ErrorCode.SOCIAL_ID_DUPLICATED)); then(cacheRepository).should(never()).deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @Test @@ -169,6 +176,7 @@ void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of()); SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", null); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); @@ -183,7 +191,7 @@ void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { .isEqualTo(ErrorCode.EMAIL_DUPLICATED)); then(cacheRepository).should(never()).deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @Test @@ -193,6 +201,7 @@ void execute_withValidInput_succeeds() { given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of()); // 소셜 인증 및 중복 확인 SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); @@ -201,14 +210,14 @@ void execute_withValidInput_succeeds() { // TX 저장 GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); - given(createUserWithSocialTx.process(any(), any(), any(), eq(true), eq(false))).willReturn(mockResponse); + given(createUserWithSocialTx.process(any(), any(), any(), eq(true), eq(false), anyList())).willReturn(mockResponse); // when GenerateTokenResponseDto result = createUserWithSocial.execute(request); // then assertThat(result).isEqualTo(mockResponse); - then(createUserWithSocialTx).should().process(eq("01012345678"), eq(request), any(), eq(true), eq(false)); + then(createUserWithSocialTx).should().process(eq("01012345678"), eq(request), any(), eq(true), eq(false), anyList()); then(cacheRepository).should().deleteAll(anyList()); } } From e0703f4ae65b7bebcf7460778945646f5c73013e Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 14:54:43 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../controller/TermsPublicController.java | 30 +++++++++++ .../controller/TermsPublicControllerSpec.java | 21 ++++++++ .../dto/PublishedTermsItemResponseDto.java | 54 +++++++++++++++++++ .../terms/usecase/GetPublishedTermsList.java | 27 ++++++++++ .../inbound/GetPublishedTermsListUseCase.java | 10 ++++ 5 files changed, 142 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicControllerSpec.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java create mode 100644 src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java new file mode 100644 index 00000000..6ad60ca0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.adapter.inbound.general.terms.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; +import com.dreamteam.alter.domain.terms.port.inbound.GetPublishedTermsListUseCase; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/public/terms") +@RequiredArgsConstructor +@Validated +public class TermsPublicController implements TermsPublicControllerSpec { + + @Resource(name = "getPublishedTermsList") + private final GetPublishedTermsListUseCase getPublishedTermsList; + + @Override + @GetMapping + public ResponseEntity>> getPublishedTermsList() { + return ResponseEntity.ok(CommonApiResponse.of(getPublishedTermsList.execute())); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicControllerSpec.java new file mode 100644 index 00000000..fa541480 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicControllerSpec.java @@ -0,0 +1,21 @@ +package com.dreamteam.alter.adapter.inbound.general.terms.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "Public - 약관") +public interface TermsPublicControllerSpec { + + @Operation(summary = "게시된 약관 목록 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "약관 목록 조회 성공") + }) + ResponseEntity>> getPublishedTermsList(); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java new file mode 100644 index 00000000..efa6a1d9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java @@ -0,0 +1,54 @@ +package com.dreamteam.alter.adapter.inbound.general.terms.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.type.TermsType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "게시된 약관 항목 응답 DTO") +public class PublishedTermsItemResponseDto { + + @Schema(description = "약관 ID", example = "1") + private Long id; + + @Schema(description = "약관 유형") + private DescribedEnumDto type; + + @Schema(description = "약관 버전", example = "v1.0") + private String version; + + @Schema(description = "약관 제목", example = "서비스 이용약관") + private String title; + + @Schema(description = "약관 문서 URL", example = "https://example.com/terms/service/v1.0") + private String docUrl; + + @Schema(description = "필수 동의 여부", example = "true") + private boolean required; + + @Schema(description = "게시 일시", example = "2025-01-01T12:00:00") + private LocalDateTime effectiveAt; + + public static PublishedTermsItemResponseDto from(Terms terms) { + return PublishedTermsItemResponseDto.builder() + .id(terms.getId()) + .type(DescribedEnumDto.of(terms.getType(), TermsType.describe())) + .version("v" + terms.getVersion()) + .title(terms.getTitle()) + .docUrl(terms.getDocUrl()) + .required(terms.isRequired()) + .effectiveAt(terms.getEffectiveAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java new file mode 100644 index 00000000..b3bcac58 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java @@ -0,0 +1,27 @@ +package com.dreamteam.alter.application.terms.usecase; + +import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; +import com.dreamteam.alter.domain.terms.port.inbound.GetPublishedTermsListUseCase; +import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import com.dreamteam.alter.domain.terms.type.TermsStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("getPublishedTermsList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetPublishedTermsList implements GetPublishedTermsListUseCase { + + private final TermsQueryRepository termsQueryRepository; + + @Override + public List execute() { + return termsQueryRepository.findByFilter(null, TermsStatus.PUBLISHED, 1, 100) + .stream() + .map(PublishedTermsItemResponseDto::from) + .toList(); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java new file mode 100644 index 00000000..76fffb84 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.terms.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; + +import java.util.List; + +public interface GetPublishedTermsListUseCase { + + List execute(); +} From 1ffd035df59eec63bf9d2a2c2e5060e82aa1e053 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 15:07:19 +0900 Subject: [PATCH 05/15] =?UTF-8?q?test:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=95=BD=EA=B4=80=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=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 --- .../service/TermsAgreementValidatorTests.java | 126 +++++++++++ .../user/usecase/CreateUserTests.java | 205 ++++++++++++++++++ .../usecase/CreateUserWithSocialTests.java | 21 ++ 3 files changed, 352 insertions(+) create mode 100644 src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java diff --git a/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java new file mode 100644 index 00000000..16844e02 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java @@ -0,0 +1,126 @@ +package com.dreamteam.alter.application.terms.service; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TermsAgreementValidator 테스트") +class TermsAgreementValidatorTests { + + @Mock + private TermsQueryRepository termsQueryRepository; + + @InjectMocks + private TermsAgreementValidator termsAgreementValidator; + + private Terms createTermsMock(Long id) { + Terms terms = mock(Terms.class); + given(terms.getId()).willReturn(id); + return terms; + } + + @Nested + @DisplayName("validateAndResolve") + class ValidateAndResolveTests { + + @Test + @DisplayName("필수 약관을 모두 동의한 경우 Terms 목록 반환") + void validateAndResolve_allRequiredAgreed_returnsTermsList() { + // given + Terms terms1 = createTermsMock(1L); + Terms terms2 = createTermsMock(2L); + given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1, terms2)); + given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); + given(termsQueryRepository.findById(2L)).willReturn(Optional.of(terms2)); + + // when + List result = termsAgreementValidator.validateAndResolve(List.of(1L, 2L)); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("필수 약관 외 선택 약관도 함께 동의한 경우 전체 Terms 목록 반환") + void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { + // given + Terms requiredTerms = createTermsMock(1L); + Terms optionalTerms = mock(Terms.class); // 필수 목록에 없으므로 getId() 호출 안 됨 + given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(requiredTerms)); + given(termsQueryRepository.findById(1L)).willReturn(Optional.of(requiredTerms)); + given(termsQueryRepository.findById(2L)).willReturn(Optional.of(optionalTerms)); + + // when + List result = termsAgreementValidator.validateAndResolve(List.of(1L, 2L)); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") + void validateAndResolve_missingRequiredTerms_throwsRequiredTermsNotAgreed() { + // given + Terms terms1 = createTermsMock(1L); + Terms terms2 = createTermsMock(2L); + given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1, terms2)); + + // when & then + assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(List.of(1L))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); + + then(termsQueryRepository).should(never()).findById(any()); + } + + @Test + @DisplayName("동의한 약관 ID가 존재하지 않는 경우 TERMS_NOT_FOUND 예외 발생") + void validateAndResolve_nonExistentTermsId_throwsTermsNotFound() { + // given + Terms terms1 = createTermsMock(1L); + given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1)); + given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); + given(termsQueryRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(List.of(1L, 999L))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.TERMS_NOT_FOUND)); + } + + @Test + @DisplayName("필수 약관이 없는 경우 동의 목록 없어도 통과") + void validateAndResolve_noRequiredTerms_emptyAgreedListPasses() { + // given + given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of()); + + // when + List result = termsAgreementValidator.validateAndResolve(List.of()); + + // then + assertThat(result).isEmpty(); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java new file mode 100644 index 00000000..0133cc1c --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java @@ -0,0 +1,205 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.type.UserGender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateUser 테스트") +class CreateUserTests { + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private SignupSessionCacheRepository cacheRepository; + + @Mock + private EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @Mock + private CreateUserTx createUserTx; + + @Mock + private TermsAgreementValidator termsAgreementValidator; + + @InjectMocks + private CreateUser createUser; + + private CreateUserRequestDto request; + + @BeforeEach + void setUp() { + request = new CreateUserRequestDto( + "signup-session-id", + null, + "Test1234!", + "김철수", + "유땡땡", + UserGender.GENDER_MALE, + "19900101", + true, + false, + List.of(1L, 2L) + ); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("회원가입 세션이 존재하지 않을 경우 SIGNUP_SESSION_NOT_EXIST 예외 발생") + void execute_signupSessionNotFound_throwsSignupSessionNotExist() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn(null); + + // when & then + assertThatThrownBy(() -> createUser.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SIGNUP_SESSION_NOT_EXIST)); + + then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + + @Test + @DisplayName("닉네임이 중복될 경우 NICKNAME_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void execute_nicknameDuplicated_throwsNicknameDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUser.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); + + then(cacheRepository).should().deleteAll(anyList()); + then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + + @Test + @DisplayName("연락처가 중복될 경우 USER_CONTACT_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void execute_contactDuplicated_throwsUserContactDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUser.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); + + then(cacheRepository).should().deleteAll(anyList()); + then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + + @Test + @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") + void execute_requiredTermsNotAgreed_throwsRequiredTermsNotAgreed() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + willThrow(new CustomException(ErrorCode.REQUIRED_TERMS_NOT_AGREED)) + .given(termsAgreementValidator).validateAndResolve(any()); + + // when & then + assertThatThrownBy(() -> createUser.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); + + then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + + @Test + @DisplayName("비밀번호 형식이 잘못된 경우 INVALID_PASSWORD_FORMAT 예외 발생") + void execute_invalidPasswordFormat_throwsInvalidPasswordFormat() { + // given + CreateUserRequestDto invalidRequest = new CreateUserRequestDto( + "signup-session-id", + null, + "password123", + "김철수", + "유땡땡", + UserGender.GENDER_MALE, + "19900101", + true, + false, + List.of(1L, 2L) + ); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> createUser.execute(invalidRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_PASSWORD_FORMAT)); + + then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + + @Test + @DisplayName("유효한 입력으로 일반 회원가입 성공") + void execute_withValidInput_succeeds() { + // given + Terms termsMock1 = mock(Terms.class); + Terms termsMock2 = mock(Terms.class); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of(termsMock1, termsMock2)); + + GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); + given(createUserTx.process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList())).willReturn(mockResponse); + + // when + GenerateTokenResponseDto result = createUser.execute(request); + + // then + assertThat(result).isEqualTo(mockResponse); + then(createUserTx).should().process(eq(request), eq("01012345678"), any(), eq(true), eq(false), anyList()); + then(cacheRepository).should().deleteAll(anyList()); + then(emailVerificationSessionStoreRepository).should(never()).deleteSession(any()); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 169f9ecf..5aa43a7f 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -194,6 +195,26 @@ void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } + @Test + @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") + void execute_requiredTermsNotAgreed_throwsRequiredTermsNotAgreed() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + willThrow(new CustomException(ErrorCode.REQUIRED_TERMS_NOT_AGREED)) + .given(termsAgreementValidator).validateAndResolve(any()); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); + + then(socialAuthenticationManager).should(never()).authenticate(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); + } + @Test @DisplayName("유효한 입력으로 소셜 회원가입 성공") void execute_withValidInput_succeeds() { From 140107c98a5a6da44a9d134d3d2e6ff614db14d9 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 15:54:13 +0900 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20UserTermsAgreement=EB=A5=BC?= =?UTF-8?q?=20type/version=20=EC=A7=81=EC=A0=91=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/terms/entity/UserTermsAgreement.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java b/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java index 97b04704..5e61a2d5 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java @@ -1,9 +1,12 @@ package com.dreamteam.alter.domain.terms.entity; +import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -38,9 +41,12 @@ public class UserTermsAgreement { @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "terms_id", nullable = false) - private Terms terms; + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 30, nullable = false) + private TermsType type; + + @Column(name = "version", length = 20, nullable = false) + private String version; @CreatedDate @Column(name = "agreed_at", nullable = false, updatable = false) @@ -49,7 +55,8 @@ public class UserTermsAgreement { public static UserTermsAgreement create(User user, Terms terms) { return UserTermsAgreement.builder() .user(user) - .terms(terms) + .type(terms.getType()) + .version(terms.getVersion()) .build(); } } From d459c18b64c0c63d34812db98cabd4b45e5fc04f Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 16:01:08 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20GetPublishedTermsList?= =?UTF-8?q?=EB=A5=BC=20=ED=83=80=EC=9E=85=EB=B3=84=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/TermsQueryRepositoryImpl.java | 16 +++++++++-- .../service/TermsAgreementValidator.java | 5 +++- .../terms/usecase/GetPublishedTermsList.java | 3 +- .../port/outbound/TermsQueryRepository.java | 6 +++- .../service/TermsAgreementValidatorTests.java | 28 ++++++++++--------- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java index 535ed4c1..ad539a94 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java @@ -6,6 +6,7 @@ import com.dreamteam.alter.domain.terms.type.TermsStatus; import com.dreamteam.alter.domain.terms.type.TermsType; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; @@ -92,13 +93,24 @@ public Optional findPublishedByTypeWithLock(TermsType type) { } @Override - public List findAllRequiredPublished() { + public List findLatestPublishedPerType() { + QTerms sub = new QTerms("sub"); + return queryFactory .selectFrom(terms) .where( terms.status.eq(TermsStatus.PUBLISHED), - terms.required.isTrue() + terms.effectiveAt.eq( + JPAExpressions + .select(sub.effectiveAt.max()) + .from(sub) + .where( + sub.type.eq(terms.type), + sub.status.eq(TermsStatus.PUBLISHED) + ) + ) ) + .orderBy(terms.type.asc()) .fetch(); } diff --git a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java index 00edddc2..4c28df64 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java +++ b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java @@ -18,7 +18,10 @@ public class TermsAgreementValidator { private final TermsQueryRepository termsQueryRepository; public List validateAndResolve(List agreedTermsIds) { - List requiredTermsList = termsQueryRepository.findAllRequiredPublished(); + List requiredTermsList = termsQueryRepository.findLatestPublishedPerType() + .stream() + .filter(Terms::isRequired) + .toList(); Set agreedSet = new HashSet<>(agreedTermsIds); boolean allRequiredAgreed = requiredTermsList.stream() diff --git a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java index b3bcac58..4b1f2fe5 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java +++ b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java @@ -3,7 +3,6 @@ import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; import com.dreamteam.alter.domain.terms.port.inbound.GetPublishedTermsListUseCase; import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; -import com.dreamteam.alter.domain.terms.type.TermsStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,7 +18,7 @@ public class GetPublishedTermsList implements GetPublishedTermsListUseCase { @Override public List execute() { - return termsQueryRepository.findByFilter(null, TermsStatus.PUBLISHED, 1, 100) + return termsQueryRepository.findLatestPublishedPerType() .stream() .map(PublishedTermsItemResponseDto::from) .toList(); diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java index 2d2b6fd8..dec834de 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/TermsQueryRepository.java @@ -19,5 +19,9 @@ public interface TermsQueryRepository { Optional findPublishedByTypeWithLock(TermsType type); - List findAllRequiredPublished(); + /** + * 각 TermsType 별로 PUBLISHED 상태인 약관 중 effective_at 이 가장 최근인 것 1건씩 반환 + * 결과 순서: TermsType 선언 순서 (SERVICE, PRIVACY, LOCATION, MARKETING) + */ + List findLatestPublishedPerType(); } diff --git a/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java index 16844e02..547e3775 100644 --- a/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java +++ b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java @@ -33,9 +33,10 @@ class TermsAgreementValidatorTests { @InjectMocks private TermsAgreementValidator termsAgreementValidator; - private Terms createTermsMock(Long id) { + private Terms createTermsMock(Long id, boolean required) { Terms terms = mock(Terms.class); given(terms.getId()).willReturn(id); + given(terms.isRequired()).willReturn(required); return terms; } @@ -47,9 +48,9 @@ class ValidateAndResolveTests { @DisplayName("필수 약관을 모두 동의한 경우 Terms 목록 반환") void validateAndResolve_allRequiredAgreed_returnsTermsList() { // given - Terms terms1 = createTermsMock(1L); - Terms terms2 = createTermsMock(2L); - given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1, terms2)); + Terms terms1 = createTermsMock(1L, true); + Terms terms2 = createTermsMock(2L, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1, terms2)); given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); given(termsQueryRepository.findById(2L)).willReturn(Optional.of(terms2)); @@ -64,9 +65,10 @@ void validateAndResolve_allRequiredAgreed_returnsTermsList() { @DisplayName("필수 약관 외 선택 약관도 함께 동의한 경우 전체 Terms 목록 반환") void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { // given - Terms requiredTerms = createTermsMock(1L); - Terms optionalTerms = mock(Terms.class); // 필수 목록에 없으므로 getId() 호출 안 됨 - given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(requiredTerms)); + Terms requiredTerms = createTermsMock(1L, true); + Terms optionalTerms = mock(Terms.class); + given(optionalTerms.isRequired()).willReturn(false); // filter 대상, getId() 호출 안 됨 + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(requiredTerms, optionalTerms)); given(termsQueryRepository.findById(1L)).willReturn(Optional.of(requiredTerms)); given(termsQueryRepository.findById(2L)).willReturn(Optional.of(optionalTerms)); @@ -81,9 +83,9 @@ void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") void validateAndResolve_missingRequiredTerms_throwsRequiredTermsNotAgreed() { // given - Terms terms1 = createTermsMock(1L); - Terms terms2 = createTermsMock(2L); - given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1, terms2)); + Terms terms1 = createTermsMock(1L, true); + Terms terms2 = createTermsMock(2L, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1, terms2)); // when & then assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(List.of(1L))) @@ -98,8 +100,8 @@ void validateAndResolve_missingRequiredTerms_throwsRequiredTermsNotAgreed() { @DisplayName("동의한 약관 ID가 존재하지 않는 경우 TERMS_NOT_FOUND 예외 발생") void validateAndResolve_nonExistentTermsId_throwsTermsNotFound() { // given - Terms terms1 = createTermsMock(1L); - given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of(terms1)); + Terms terms1 = createTermsMock(1L, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1)); given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); given(termsQueryRepository.findById(999L)).willReturn(Optional.empty()); @@ -114,7 +116,7 @@ void validateAndResolve_nonExistentTermsId_throwsTermsNotFound() { @DisplayName("필수 약관이 없는 경우 동의 목록 없어도 통과") void validateAndResolve_noRequiredTerms_emptyAgreedListPasses() { // given - given(termsQueryRepository.findAllRequiredPublished()).willReturn(List.of()); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of()); // when List result = termsAgreementValidator.validateAndResolve(List.of()); From a653bcfd2a61f5817dce940f5de656a7ab88452e Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 16:16:57 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=EB=A5=BC=20?= =?UTF-8?q?ID=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20TermsType=20Set?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/CreateUserRequestDto.java | 7 +- .../dto/CreateUserWithSocialRequestDto.java | 7 +- .../service/TermsAgreementValidator.java | 21 ++---- .../application/user/usecase/CreateUser.java | 2 +- .../user/usecase/CreateUserWithSocial.java | 2 +- .../service/TermsAgreementValidatorTests.java | 73 +++++++++---------- .../user/usecase/CreateUserTests.java | 6 +- .../usecase/CreateUserWithSocialTests.java | 4 +- 8 files changed, 59 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java index 980ae75b..ae50d943 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java @@ -1,5 +1,6 @@ package com.dreamteam.alter.adapter.inbound.general.user.dto; +import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.type.UserGender; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -10,7 +11,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.List; +import java.util.Set; @Getter @NoArgsConstructor @@ -61,7 +62,7 @@ public class CreateUserRequestDto { private Boolean nightNotificationConsent; @NotNull - @Schema(description = "동의한 약관 ID 목록", example = "[1, 2, 3]") - private List agreedTermsIds; + @Schema(description = "동의한 약관 타입 목록", example = "[\"SERVICE\", \"PRIVACY\"]") + private Set agreedTermsTypes; } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java index 7bd82a8a..8f83dd24 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -1,5 +1,6 @@ package com.dreamteam.alter.adapter.inbound.general.user.dto; +import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.type.PlatformType; import com.dreamteam.alter.domain.user.type.SocialProvider; import com.dreamteam.alter.domain.user.type.UserGender; @@ -13,7 +14,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.List; +import java.util.Set; @Getter @NoArgsConstructor @@ -69,8 +70,8 @@ public class CreateUserWithSocialRequestDto { private Boolean nightNotificationConsent; @NotNull - @Schema(description = "동의한 약관 ID 목록", example = "[1, 2, 3]") - private List agreedTermsIds; + @Schema(description = "동의한 약관 타입 목록", example = "[\"SERVICE\", \"PRIVACY\"]") + private Set agreedTermsTypes; @AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다") private boolean isWebPlatformValid() { diff --git a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java index 4c28df64..fbb862b2 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java +++ b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java @@ -4,10 +4,10 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import com.dreamteam.alter.domain.terms.type.TermsType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.HashSet; import java.util.List; import java.util.Set; @@ -17,24 +17,19 @@ public class TermsAgreementValidator { private final TermsQueryRepository termsQueryRepository; - public List validateAndResolve(List agreedTermsIds) { - List requiredTermsList = termsQueryRepository.findLatestPublishedPerType() - .stream() - .filter(Terms::isRequired) - .toList(); + public List validateAndResolve(Set agreedTermsTypes) { + List latestPublishedTerms = termsQueryRepository.findLatestPublishedPerType(); - Set agreedSet = new HashSet<>(agreedTermsIds); - boolean allRequiredAgreed = requiredTermsList.stream() - .allMatch(t -> agreedSet.contains(t.getId())); + boolean allRequiredAgreed = latestPublishedTerms.stream() + .filter(Terms::isRequired) + .allMatch(t -> agreedTermsTypes.contains(t.getType())); if (!allRequiredAgreed) { throw new CustomException(ErrorCode.REQUIRED_TERMS_NOT_AGREED); } - return agreedTermsIds.stream() - .map(id -> termsQueryRepository.findById(id) - .orElseThrow(() -> new CustomException( - ErrorCode.TERMS_NOT_FOUND, "약관 ID: " + id))) + return latestPublishedTerms.stream() + .filter(t -> agreedTermsTypes.contains(t.getType())) .toList(); } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java index d85370bc..0a891dc2 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java @@ -46,7 +46,7 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { validateDuplication(request, contact, sessionIdKey); // 약관 동의 검증 - List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsIds()); + List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsTypes()); // 비밀번호 형식 검증 if (!PasswordValidator.isValid(request.getPassword())) { diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index 36f6a323..dcc1050f 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -48,7 +48,7 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) validateDuplication(request, contact, sessionIdKey); // 약관 동의 검증 - List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsIds()); + List agreedTerms = termsAgreementValidator.validateAndResolve(request.getAgreedTermsTypes()); // 소셜 인증 OauthToken oauthToken = request.getOauthToken() != null diff --git a/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java index 547e3775..fa15fd80 100644 --- a/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java +++ b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java @@ -4,6 +4,7 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import com.dreamteam.alter.domain.terms.type.TermsType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -13,15 +14,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; -import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("TermsAgreementValidator 테스트") @@ -33,9 +31,9 @@ class TermsAgreementValidatorTests { @InjectMocks private TermsAgreementValidator termsAgreementValidator; - private Terms createTermsMock(Long id, boolean required) { + private Terms createTermsMock(TermsType type, boolean required) { Terms terms = mock(Terms.class); - given(terms.getId()).willReturn(id); + given(terms.getType()).willReturn(type); given(terms.isRequired()).willReturn(required); return terms; } @@ -48,14 +46,14 @@ class ValidateAndResolveTests { @DisplayName("필수 약관을 모두 동의한 경우 Terms 목록 반환") void validateAndResolve_allRequiredAgreed_returnsTermsList() { // given - Terms terms1 = createTermsMock(1L, true); - Terms terms2 = createTermsMock(2L, true); - given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1, terms2)); - given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); - given(termsQueryRepository.findById(2L)).willReturn(Optional.of(terms2)); + Terms serviceTerms = createTermsMock(TermsType.SERVICE, true); + Terms privacyTerms = createTermsMock(TermsType.PRIVACY, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(serviceTerms, privacyTerms)); // when - List result = termsAgreementValidator.validateAndResolve(List.of(1L, 2L)); + List result = termsAgreementValidator.validateAndResolve( + Set.of(TermsType.SERVICE, TermsType.PRIVACY) + ); // then assertThat(result).hasSize(2); @@ -65,15 +63,14 @@ void validateAndResolve_allRequiredAgreed_returnsTermsList() { @DisplayName("필수 약관 외 선택 약관도 함께 동의한 경우 전체 Terms 목록 반환") void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { // given - Terms requiredTerms = createTermsMock(1L, true); - Terms optionalTerms = mock(Terms.class); - given(optionalTerms.isRequired()).willReturn(false); // filter 대상, getId() 호출 안 됨 - given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(requiredTerms, optionalTerms)); - given(termsQueryRepository.findById(1L)).willReturn(Optional.of(requiredTerms)); - given(termsQueryRepository.findById(2L)).willReturn(Optional.of(optionalTerms)); + Terms serviceTerms = createTermsMock(TermsType.SERVICE, true); + Terms marketingTerms = createTermsMock(TermsType.MARKETING, false); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(serviceTerms, marketingTerms)); // when - List result = termsAgreementValidator.validateAndResolve(List.of(1L, 2L)); + List result = termsAgreementValidator.validateAndResolve( + Set.of(TermsType.SERVICE, TermsType.MARKETING) + ); // then assertThat(result).hasSize(2); @@ -83,43 +80,41 @@ void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") void validateAndResolve_missingRequiredTerms_throwsRequiredTermsNotAgreed() { // given - Terms terms1 = createTermsMock(1L, true); - Terms terms2 = createTermsMock(2L, true); - given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1, terms2)); + Terms serviceTerms = createTermsMock(TermsType.SERVICE, true); + Terms privacyTerms = createTermsMock(TermsType.PRIVACY, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(serviceTerms, privacyTerms)); // when & then - assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(List.of(1L))) + assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(Set.of(TermsType.SERVICE))) .isInstanceOf(CustomException.class) .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); - - then(termsQueryRepository).should(never()).findById(any()); } @Test - @DisplayName("동의한 약관 ID가 존재하지 않는 경우 TERMS_NOT_FOUND 예외 발생") - void validateAndResolve_nonExistentTermsId_throwsTermsNotFound() { - // given - Terms terms1 = createTermsMock(1L, true); - given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(terms1)); - given(termsQueryRepository.findById(1L)).willReturn(Optional.of(terms1)); - given(termsQueryRepository.findById(999L)).willReturn(Optional.empty()); + @DisplayName("PUBLISHED 되지 않은 타입 동의 시 해당 타입 무시하고 동의된 타입만 반환") + void validateAndResolve_unpublishedTypeAgreed_ignoresUnpublishedType() { + // given - LOCATION은 PUBLISHED 약관 없음 (findLatestPublishedPerType 결과에 미포함) + Terms serviceTerms = createTermsMock(TermsType.SERVICE, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(serviceTerms)); - // when & then - assertThatThrownBy(() -> termsAgreementValidator.validateAndResolve(List.of(1L, 999L))) - .isInstanceOf(CustomException.class) - .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) - .isEqualTo(ErrorCode.TERMS_NOT_FOUND)); + // when - LOCATION 타입도 함께 동의했지만 PUBLISHED 약관이 없으므로 무시됨 + List result = termsAgreementValidator.validateAndResolve( + Set.of(TermsType.SERVICE, TermsType.LOCATION) + ); + + // then + assertThat(result).hasSize(1); } @Test @DisplayName("필수 약관이 없는 경우 동의 목록 없어도 통과") - void validateAndResolve_noRequiredTerms_emptyAgreedListPasses() { + void validateAndResolve_noRequiredTerms_emptyAgreedSetPasses() { // given given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of()); // when - List result = termsAgreementValidator.validateAndResolve(List.of()); + List result = termsAgreementValidator.validateAndResolve(Set.of()); // then assertThat(result).isEmpty(); diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java index 0133cc1c..02588c5c 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import com.dreamteam.alter.domain.user.type.UserGender; @@ -22,6 +23,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -71,7 +73,7 @@ void setUp() { "19900101", true, false, - List.of(1L, 2L) + Set.of(TermsType.SERVICE, TermsType.PRIVACY) ); } @@ -162,7 +164,7 @@ void execute_invalidPasswordFormat_throwsInvalidPasswordFormat() { "19900101", true, false, - List.of(1L, 2L) + Set.of(TermsType.SERVICE, TermsType.PRIVACY) ); given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 5aa43a7f..47b529f1 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -79,7 +81,7 @@ void setUp() { "19900101", true, false, - List.of(1L, 2L) + Set.of(TermsType.SERVICE, TermsType.PRIVACY) ); } From 991230cb5d25c0886b282b30da0ffcf071795d8c Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 17:02:18 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20GetPublishedTermsListUseCase?= =?UTF-8?q?=20=ED=97=A5=EC=82=AC=EA=B3=A0=EB=82=A0=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=20=EC=9C=84=EB=B0=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../general/terms/controller/TermsPublicController.java | 7 ++++++- .../application/terms/usecase/GetPublishedTermsList.java | 9 +++------ .../terms/port/inbound/GetPublishedTermsListUseCase.java | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java index 6ad60ca0..7a63b128 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java @@ -25,6 +25,11 @@ public class TermsPublicController implements TermsPublicControllerSpec { @Override @GetMapping public ResponseEntity>> getPublishedTermsList() { - return ResponseEntity.ok(CommonApiResponse.of(getPublishedTermsList.execute())); + return ResponseEntity.ok(CommonApiResponse.of( + getPublishedTermsList.execute() + .stream() + .map(PublishedTermsItemResponseDto::from) + .toList() + )); } } diff --git a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java index 4b1f2fe5..0ee9a86b 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java +++ b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java @@ -1,6 +1,6 @@ package com.dreamteam.alter.application.terms.usecase; -import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; +import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.terms.port.inbound.GetPublishedTermsListUseCase; import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; import lombok.RequiredArgsConstructor; @@ -17,10 +17,7 @@ public class GetPublishedTermsList implements GetPublishedTermsListUseCase { private final TermsQueryRepository termsQueryRepository; @Override - public List execute() { - return termsQueryRepository.findLatestPublishedPerType() - .stream() - .map(PublishedTermsItemResponseDto::from) - .toList(); + public List execute() { + return termsQueryRepository.findLatestPublishedPerType(); } } diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java index 76fffb84..1425dd04 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java @@ -1,10 +1,10 @@ package com.dreamteam.alter.domain.terms.port.inbound; -import com.dreamteam.alter.adapter.inbound.general.terms.dto.PublishedTermsItemResponseDto; +import com.dreamteam.alter.domain.terms.entity.Terms; import java.util.List; public interface GetPublishedTermsListUseCase { - List execute(); + List execute(); } From cd4f0883deb78e07f29892e4582b630b51bb6921 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 17:03:57 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor:=20findLatestPublishedPerType=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20effective=5Fat=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/TermsQueryRepositoryImpl.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java index ad539a94..c3c0f657 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/TermsQueryRepositoryImpl.java @@ -8,6 +8,9 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; + +import java.util.Comparator; +import java.util.stream.Collectors; import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -96,7 +99,7 @@ public Optional findPublishedByTypeWithLock(TermsType type) { public List findLatestPublishedPerType() { QTerms sub = new QTerms("sub"); - return queryFactory + List result = queryFactory .selectFrom(terms) .where( terms.status.eq(TermsStatus.PUBLISHED), @@ -110,8 +113,20 @@ public List findLatestPublishedPerType() { ) ) ) - .orderBy(terms.type.asc()) + .orderBy(terms.type.asc(), terms.effectiveAt.desc(), terms.id.desc()) .fetch(); + + // 동일 effective_at 중복 방어: 타입별 id 내림차순 기준 첫 번째 1건만 취함 + return result.stream() + .collect(Collectors.toMap( + Terms::getType, + t -> t, + (existing, replacement) -> existing + )) + .values() + .stream() + .sorted(Comparator.comparing(Terms::getType)) + .toList(); } private BooleanExpression notDeleted() { From 80454be92ad40772283409a3bce8f77c170105b4 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 17:05:28 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20TERMS=5FNOT=5FFOUND=20ErrorCode=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dreamteam/alter/common/exception/ErrorCode.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 61a81a1b..442b9c8d 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -25,7 +25,6 @@ public enum ErrorCode { SOCIAL_UNLINK_NOT_ALLOWED(400, "A016", "비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다."), INVALID_CURRENT_PASSWORD(400, "A017", "현재 비밀번호가 올바르지 않습니다."), REQUIRED_TERMS_NOT_AGREED(400, "A018", "필수 약관에 모두 동의해야 합니다."), - TERMS_NOT_FOUND(400, "A019", "존재하지 않는 약관입니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), From 52b66bb4af3c59300780d08de565271b0ef89279 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 17:44:05 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20TermsAgreementValidator?= =?UTF-8?q?=EC=97=90=20@Transactional(readOnly=20=3D=20true)=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 --- .../application/terms/service/TermsAgreementValidator.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java index fbb862b2..9bc25f6f 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java +++ b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java @@ -7,6 +7,7 @@ import com.dreamteam.alter.domain.terms.type.TermsType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Set; @@ -17,6 +18,7 @@ public class TermsAgreementValidator { private final TermsQueryRepository termsQueryRepository; + @Transactional(readOnly = true) public List validateAndResolve(Set agreedTermsTypes) { List latestPublishedTerms = termsQueryRepository.findLatestPublishedPerType(); From 29d7c9631f5c0219197ee111914ee8f3533c40a3 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 17:54:45 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20UserTermsAgreementRepository?= =?UTF-8?q?=20=ED=97=A5=EC=82=AC=EA=B3=A0=EB=82=A0=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTermsAgreementJpaRepository.java | 7 ++++++ .../UserTermsAgreementRepositoryImpl.java | 22 +++++++++++++++++++ .../UserTermsAgreementRepository.java | 6 +++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementJpaRepository.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementRepositoryImpl.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementJpaRepository.java new file mode 100644 index 00000000..511f201e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.terms.persistence; + +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserTermsAgreementJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementRepositoryImpl.java new file mode 100644 index 00000000..638143b4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/terms/persistence/UserTermsAgreementRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.outbound.terms.persistence; + +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; +import com.dreamteam.alter.domain.terms.port.outbound.UserTermsAgreementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +@Transactional +public class UserTermsAgreementRepositoryImpl implements UserTermsAgreementRepository { + + private final UserTermsAgreementJpaRepository userTermsAgreementJpaRepository; + + @Override + public List saveAll(List agreements) { + return userTermsAgreementJpaRepository.saveAll(agreements); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java index 784b1c8c..f8b8a3ec 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java @@ -1,7 +1,9 @@ package com.dreamteam.alter.domain.terms.port.outbound; import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; -import org.springframework.data.jpa.repository.JpaRepository; -public interface UserTermsAgreementRepository extends JpaRepository { +import java.util.List; + +public interface UserTermsAgreementRepository { + List saveAll(List agreements); } From f2319fa6a23c07bf9eeebb100a5cb95d460a855f Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 7 May 2026 19:09:31 +0900 Subject: [PATCH 14/15] =?UTF-8?q?test:=20anyList()=20=EB=8A=90=EC=8A=A8?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EA=B5=AC=EC=B2=B4?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=20=EC=9D=B8=EC=9E=90=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/usecase/CreateUserTests.java | 17 ++++++++++------ .../usecase/CreateUserWithSocialTests.java | 20 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java index 02588c5c..302ffa95 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java @@ -30,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -109,7 +110,8 @@ void execute_nicknameDuplicated_throwsNicknameDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @@ -127,7 +129,8 @@ void execute_contactDuplicated_throwsUserContactDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); then(createUserTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @@ -186,21 +189,23 @@ void execute_withValidInput_succeeds() { // given Terms termsMock1 = mock(Terms.class); Terms termsMock2 = mock(Terms.class); + List agreedTerms = List.of(termsMock1, termsMock2); given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of(termsMock1, termsMock2)); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(agreedTerms); GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); - given(createUserTx.process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList())).willReturn(mockResponse); + given(createUserTx.process(any(), any(), any(), anyBoolean(), anyBoolean(), eq(agreedTerms))).willReturn(mockResponse); // when GenerateTokenResponseDto result = createUser.execute(request); // then assertThat(result).isEqualTo(mockResponse); - then(createUserTx).should().process(eq(request), eq("01012345678"), any(), eq(true), eq(false), anyList()); - then(cacheRepository).should().deleteAll(anyList()); + then(createUserTx).should().process(eq(request), eq("01012345678"), any(), eq(true), eq(false), eq(agreedTerms)); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); then(emailVerificationSessionStoreRepository).should(never()).deleteSession(any()); } } diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 47b529f1..2bc28fb9 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.application.terms.service.TermsAgreementValidator; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.terms.type.TermsType; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; @@ -33,6 +34,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -126,7 +128,8 @@ void execute_nicknameDuplicated_throwsNicknameDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @@ -144,7 +147,8 @@ void execute_contactDuplicated_throwsUserContactDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean(), anyList()); } @@ -221,10 +225,13 @@ void execute_requiredTermsNotAgreed_throwsRequiredTermsNotAgreed() { @DisplayName("유효한 입력으로 소셜 회원가입 성공") void execute_withValidInput_succeeds() { // given - 기본 세션 및 검증 + Terms termsMock1 = mock(Terms.class); + Terms termsMock2 = mock(Terms.class); + List agreedTerms = List.of(termsMock1, termsMock2); given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - given(termsAgreementValidator.validateAndResolve(any())).willReturn(List.of()); + given(termsAgreementValidator.validateAndResolve(any())).willReturn(agreedTerms); // 소셜 인증 및 중복 확인 SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); @@ -233,15 +240,16 @@ void execute_withValidInput_succeeds() { // TX 저장 GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); - given(createUserWithSocialTx.process(any(), any(), any(), eq(true), eq(false), anyList())).willReturn(mockResponse); + given(createUserWithSocialTx.process(any(), any(), any(), eq(true), eq(false), eq(agreedTerms))).willReturn(mockResponse); // when GenerateTokenResponseDto result = createUserWithSocial.execute(request); // then assertThat(result).isEqualTo(mockResponse); - then(createUserWithSocialTx).should().process(eq("01012345678"), eq(request), any(), eq(true), eq(false), anyList()); - then(cacheRepository).should().deleteAll(anyList()); + then(createUserWithSocialTx).should().process(eq("01012345678"), eq(request), any(), eq(true), eq(false), eq(agreedTerms)); + then(cacheRepository).should().deleteAll(argThat(list -> + list.containsAll(List.of("SIGNUP:PENDING:signup-session-id", "SIGNUP:CONTACT:01012345678")))); } } } From 509338e445de12da5a280fa8214e48ee59ba94bb Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 8 May 2026 16:58:06 +0900 Subject: [PATCH 15/15] =?UTF-8?q?refactor:=20GetPublishedTermsList=20UseCa?= =?UTF-8?q?se=20=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85=EC=9D=84=20Result?= =?UTF-8?q?=20=EB=A0=88=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/PublishedTermsItemResponseDto.java | 18 ++++++------ .../terms/usecase/GetPublishedTermsList.java | 9 ++++-- .../inbound/GetPublishedTermsListUseCase.java | 4 +-- .../result/GetPublishedTermsListResult.java | 28 +++++++++++++++++++ 4 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/domain/terms/result/GetPublishedTermsListResult.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java index efa6a1d9..9eed742c 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/dto/PublishedTermsItemResponseDto.java @@ -1,7 +1,7 @@ package com.dreamteam.alter.adapter.inbound.general.terms.dto; import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; -import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.result.GetPublishedTermsListResult; import com.dreamteam.alter.domain.terms.type.TermsType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; @@ -40,15 +40,15 @@ public class PublishedTermsItemResponseDto { @Schema(description = "게시 일시", example = "2025-01-01T12:00:00") private LocalDateTime effectiveAt; - public static PublishedTermsItemResponseDto from(Terms terms) { + public static PublishedTermsItemResponseDto from(GetPublishedTermsListResult result) { return PublishedTermsItemResponseDto.builder() - .id(terms.getId()) - .type(DescribedEnumDto.of(terms.getType(), TermsType.describe())) - .version("v" + terms.getVersion()) - .title(terms.getTitle()) - .docUrl(terms.getDocUrl()) - .required(terms.isRequired()) - .effectiveAt(terms.getEffectiveAt()) + .id(result.id()) + .type(DescribedEnumDto.of(result.type(), TermsType.describe())) + .version("v" + result.version()) + .title(result.title()) + .docUrl(result.docUrl()) + .required(result.required()) + .effectiveAt(result.effectiveAt()) .build(); } } diff --git a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java index 0ee9a86b..d07bd875 100644 --- a/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java +++ b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java @@ -1,8 +1,8 @@ package com.dreamteam.alter.application.terms.usecase; -import com.dreamteam.alter.domain.terms.entity.Terms; import com.dreamteam.alter.domain.terms.port.inbound.GetPublishedTermsListUseCase; import com.dreamteam.alter.domain.terms.port.outbound.TermsQueryRepository; +import com.dreamteam.alter.domain.terms.result.GetPublishedTermsListResult; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +17,10 @@ public class GetPublishedTermsList implements GetPublishedTermsListUseCase { private final TermsQueryRepository termsQueryRepository; @Override - public List execute() { - return termsQueryRepository.findLatestPublishedPerType(); + public List execute() { + return termsQueryRepository.findLatestPublishedPerType() + .stream() + .map(GetPublishedTermsListResult::from) + .toList(); } } diff --git a/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java index 1425dd04..bef462ef 100644 --- a/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/inbound/GetPublishedTermsListUseCase.java @@ -1,10 +1,10 @@ package com.dreamteam.alter.domain.terms.port.inbound; -import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.result.GetPublishedTermsListResult; import java.util.List; public interface GetPublishedTermsListUseCase { - List execute(); + List execute(); } diff --git a/src/main/java/com/dreamteam/alter/domain/terms/result/GetPublishedTermsListResult.java b/src/main/java/com/dreamteam/alter/domain/terms/result/GetPublishedTermsListResult.java new file mode 100644 index 00000000..6c218c02 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/result/GetPublishedTermsListResult.java @@ -0,0 +1,28 @@ +package com.dreamteam.alter.domain.terms.result; + +import com.dreamteam.alter.domain.terms.entity.Terms; +import com.dreamteam.alter.domain.terms.type.TermsType; + +import java.time.LocalDateTime; + +public record GetPublishedTermsListResult( + Long id, + TermsType type, + String title, + String docUrl, + boolean required, + String version, + LocalDateTime effectiveAt +) { + public static GetPublishedTermsListResult from(Terms terms) { + return new GetPublishedTermsListResult( + terms.getId(), + terms.getType(), + terms.getTitle(), + terms.getDocUrl(), + terms.isRequired(), + terms.getVersion(), + terms.getEffectiveAt() + ); + } +}