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..7a63b128 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/terms/controller/TermsPublicController.java @@ -0,0 +1,35 @@ +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() + .stream() + .map(PublishedTermsItemResponseDto::from) + .toList() + )); + } +} 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..9eed742c --- /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.result.GetPublishedTermsListResult; +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(GetPublishedTermsListResult result) { + return PublishedTermsItemResponseDto.builder() + .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/adapter/inbound/general/user/dto/CreateUserRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserRequestDto.java index 430c4c60..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,6 +11,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Set; + @Getter @NoArgsConstructor @AllArgsConstructor @@ -58,4 +61,8 @@ public class CreateUserRequestDto { @Schema(description = "야간 알림 수신 동의 여부", example = "false") private Boolean nightNotificationConsent; + @NotNull + @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 2fc34b26..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,6 +14,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Set; + @Getter @NoArgsConstructor @AllArgsConstructor @@ -66,6 +69,10 @@ public class CreateUserWithSocialRequestDto { @Schema(description = "야간 알림 수신 동의 여부", example = "false") private Boolean nightNotificationConsent; + @NotNull + @Schema(description = "동의한 약관 타입 목록", example = "[\"SERVICE\", \"PRIVACY\"]") + private Set agreedTermsTypes; + @AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다") private boolean isWebPlatformValid() { if (platformType != PlatformType.WEB) return true; 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..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 @@ -6,7 +6,11 @@ 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 java.util.Comparator; +import java.util.stream.Collectors; import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -91,6 +95,40 @@ public Optional findPublishedByTypeWithLock(TermsType type) { ); } + @Override + public List findLatestPublishedPerType() { + QTerms sub = new QTerms("sub"); + + List result = queryFactory + .selectFrom(terms) + .where( + terms.status.eq(TermsStatus.PUBLISHED), + 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(), 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() { return terms.status.ne(TermsStatus.DELETED); } 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/application/terms/service/TermsAgreementValidator.java b/src/main/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidator.java new file mode 100644 index 00000000..9bc25f6f --- /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 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; + +@Component("termsAgreementValidator") +@RequiredArgsConstructor +public class TermsAgreementValidator { + + private final TermsQueryRepository termsQueryRepository; + + @Transactional(readOnly = true) + public List validateAndResolve(Set agreedTermsTypes) { + List latestPublishedTerms = termsQueryRepository.findLatestPublishedPerType(); + + boolean allRequiredAgreed = latestPublishedTerms.stream() + .filter(Terms::isRequired) + .allMatch(t -> agreedTermsTypes.contains(t.getType())); + + if (!allRequiredAgreed) { + throw new CustomException(ErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + + return latestPublishedTerms.stream() + .filter(t -> agreedTermsTypes.contains(t.getType())) + .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 new file mode 100644 index 00000000..d07bd875 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/terms/usecase/GetPublishedTermsList.java @@ -0,0 +1,26 @@ +package com.dreamteam.alter.application.terms.usecase; + +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; + +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.findLatestPublishedPerType() + .stream() + .map(GetPublishedTermsListResult::from) + .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..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 @@ -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.getAgreedTermsTypes()); + // 비밀번호 형식 검증 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..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 @@ -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.getAgreedTermsTypes()); + // 소셜 인증 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..442b9c8d 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,7 @@ 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", "필수 약관에 모두 동의해야 합니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), 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..5e61a2d5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/entity/UserTermsAgreement.java @@ -0,0 +1,62 @@ +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; +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; + + @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) + private LocalDateTime agreedAt; + + public static UserTermsAgreement create(User user, Terms terms) { + return UserTermsAgreement.builder() + .user(user) + .type(terms.getType()) + .version(terms.getVersion()) + .build(); + } +} 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..bef462ef --- /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.domain.terms.result.GetPublishedTermsListResult; + +import java.util.List; + +public interface GetPublishedTermsListUseCase { + + List execute(); +} 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..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 @@ -18,4 +18,10 @@ public interface TermsQueryRepository { Optional findPublishedByType(TermsType type); Optional findPublishedByTypeWithLock(TermsType type); + + /** + * 각 TermsType 별로 PUBLISHED 상태인 약관 중 effective_at 이 가장 최근인 것 1건씩 반환 + * 결과 순서: TermsType 선언 순서 (SERVICE, PRIVACY, LOCATION, MARKETING) + */ + List findLatestPublishedPerType(); } 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..f8b8a3ec --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/terms/port/outbound/UserTermsAgreementRepository.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.terms.port.outbound; + +import com.dreamteam.alter.domain.terms.entity.UserTermsAgreement; + +import java.util.List; + +public interface UserTermsAgreementRepository { + List saveAll(List agreements); +} 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() + ); + } +} 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..fa15fd80 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/terms/service/TermsAgreementValidatorTests.java @@ -0,0 +1,123 @@ +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 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; +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.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TermsAgreementValidator 테스트") +class TermsAgreementValidatorTests { + + @Mock + private TermsQueryRepository termsQueryRepository; + + @InjectMocks + private TermsAgreementValidator termsAgreementValidator; + + private Terms createTermsMock(TermsType type, boolean required) { + Terms terms = mock(Terms.class); + given(terms.getType()).willReturn(type); + given(terms.isRequired()).willReturn(required); + return terms; + } + + @Nested + @DisplayName("validateAndResolve") + class ValidateAndResolveTests { + + @Test + @DisplayName("필수 약관을 모두 동의한 경우 Terms 목록 반환") + void validateAndResolve_allRequiredAgreed_returnsTermsList() { + // given + 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( + Set.of(TermsType.SERVICE, TermsType.PRIVACY) + ); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("필수 약관 외 선택 약관도 함께 동의한 경우 전체 Terms 목록 반환") + void validateAndResolve_requiredAndOptionalAgreed_returnsAllTerms() { + // given + 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( + Set.of(TermsType.SERVICE, TermsType.MARKETING) + ); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("필수 약관 미동의 시 REQUIRED_TERMS_NOT_AGREED 예외 발생") + void validateAndResolve_missingRequiredTerms_throwsRequiredTermsNotAgreed() { + // given + 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(Set.of(TermsType.SERVICE))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); + } + + @Test + @DisplayName("PUBLISHED 되지 않은 타입 동의 시 해당 타입 무시하고 동의된 타입만 반환") + void validateAndResolve_unpublishedTypeAgreed_ignoresUnpublishedType() { + // given - LOCATION은 PUBLISHED 약관 없음 (findLatestPublishedPerType 결과에 미포함) + Terms serviceTerms = createTermsMock(TermsType.SERVICE, true); + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of(serviceTerms)); + + // when - LOCATION 타입도 함께 동의했지만 PUBLISHED 약관이 없으므로 무시됨 + List result = termsAgreementValidator.validateAndResolve( + Set.of(TermsType.SERVICE, TermsType.LOCATION) + ); + + // then + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("필수 약관이 없는 경우 동의 목록 없어도 통과") + void validateAndResolve_noRequiredTerms_emptyAgreedSetPasses() { + // given + given(termsQueryRepository.findLatestPublishedPerType()).willReturn(List.of()); + + // when + 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 new file mode 100644 index 00000000..302ffa95 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserTests.java @@ -0,0 +1,212 @@ +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.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; +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 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.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; +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, + Set.of(TermsType.SERVICE, TermsType.PRIVACY) + ); + } + + @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(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()); + } + + @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(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()); + } + + @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, + Set.of(TermsType.SERVICE, TermsType.PRIVACY) + ); + 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); + 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(agreedTerms); + + GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); + 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), 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 43125d89..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 @@ -5,8 +5,11 @@ 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.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.port.outbound.UserSocialQueryRepository; @@ -22,16 +25,20 @@ import org.mockito.Mock; 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.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; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -54,6 +61,9 @@ class CreateUserWithSocialTests { @Mock private CreateUserWithSocialTx createUserWithSocialTx; + @Mock + private TermsAgreementValidator termsAgreementValidator; + @InjectMocks private CreateUserWithSocial createUserWithSocial; @@ -72,7 +82,8 @@ void setUp() { UserGender.GENDER_MALE, "19900101", true, - false + false, + Set.of(TermsType.SERVICE, TermsType.PRIVACY) ); } @@ -101,7 +112,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 @@ -117,8 +128,9 @@ void execute_nicknameDuplicated_throwsNicknameDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + 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()); } @Test @@ -135,8 +147,9 @@ void execute_contactDuplicated_throwsUserContactDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); - then(cacheRepository).should().deleteAll(anyList()); - then(createUserWithSocialTx).should(never()).process(any(), any(), any(), anyBoolean(), anyBoolean()); + 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()); } @Test @@ -146,6 +159,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 +173,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 +183,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,16 +198,40 @@ 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 + @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() { // 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(agreedTerms); // 소셜 인증 및 중복 확인 SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); @@ -201,15 +240,16 @@ 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), 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)); - 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")))); } } }