From d32666488c3a07a8d5b2db2d5d8582e240737572 Mon Sep 17 00:00:00 2001 From: woo-in Date: Wed, 9 Sep 2026 22:18:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=80=EC=9E=85=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=A0=EC=9A=A9=EC=A0=90=EC=88=98=C2=B7=EB=B9=84=EA=B8=88?= =?UTF-8?q?=EC=9C=B5=20=EB=82=A9=EB=B6=80=20=EC=9D=B4=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신용관리 화면 여섯 조각 중 카드·대출은 마이데이터 연동이 채우지만 (MockMydataProvisioner가 복제한 목데이터를 동기화가 옮겨온다), 신용점수 이력과 비금융 납부 이력은 어디서도 만들어지지 않아 신규 가입자에게는 빈 화면이 떴다. data.sql이 데모 계정 것만 넣고 그 두 테이블에 쓰는 코드가 없었다. CreditDemoProvisioner를 두고 가입(AuthService.signup)과 마이데이터 연동(MydataConnectService.connect) 양쪽에서 부른다. 연동을 건너뛴 회원도 화면을 볼 수 있어야 해서 가입에 걸고, 연동 쪽은 그 전에 가입한 회원과 시드 계정을 메우는 보정으로 남긴다. MockMydataProvisioner와 같이 코드에 점수·금액을 다시 적지 않고 데모 계정(user1@test.com)의 DB 행을 복제한다. 하드코딩하면 시드와 두 벌이 되어 한쪽만 고쳤을 때 조용히 어긋난다. 네이티브 insert...select를 쓰는 이유는 credit_score.updated_at이 @UpdateTimestamp라 엔티티로 저장하면 Hibernate가 '지금'으로 덮어쓰기 때문이다. 화면은 이 값을 각 기록의 월 표시로 쓰므로 여섯 건이 전부 같은 달로 뜬다. 이미 있는 NonFinancialPaymentService.sync는 쓰지 않았다. 그쪽은 '오늘' 기준 12개월치를 생성해서 시드가 맞춰 둔 6개월·연체 위치와 어긋난다. 시드 그대로여야 해서 생성이 아니라 복제여야 한다. 멱등성: 납부 이력은 insert ignore + 유일 제약이, 신용점수는 유일 제약이 없어 existsByUserUserId 검사가 막는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S9r6EsJn3MZ3uUL1PuuA9g --- .../dday/domain/auth/service/AuthService.java | 14 +++ .../repository/CreditScoreRepository.java | 29 +++++ .../NonFinancialPaymentRepository.java | 28 +++++ .../credit/service/CreditDemoProvisioner.java | 90 +++++++++++++++ .../mydata/service/MydataConnectService.java | 17 ++- .../domain/auth/service/AuthServiceTest.java | 32 +++++- .../service/CreditDemoProvisionerTest.java | 105 ++++++++++++++++++ 7 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/dday/domain/credit/service/CreditDemoProvisioner.java create mode 100644 backend/src/test/java/com/dday/domain/credit/service/CreditDemoProvisionerTest.java diff --git a/backend/src/main/java/com/dday/domain/auth/service/AuthService.java b/backend/src/main/java/com/dday/domain/auth/service/AuthService.java index fb53b45..6fa7a89 100644 --- a/backend/src/main/java/com/dday/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/dday/domain/auth/service/AuthService.java @@ -6,6 +6,7 @@ import com.dday.domain.auth.entity.PhoneVerification; import com.dday.domain.auth.entity.VerificationPurpose; import com.dday.domain.auth.repository.PhoneVerificationRepository; +import com.dday.domain.credit.service.CreditDemoProvisioner; import com.dday.domain.user.entity.User; import com.dday.domain.user.entity.UserStatus; import com.dday.domain.user.repository.UserRepository; @@ -53,6 +54,9 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + /** ⚠️ 시연용이다. 실제 신용평가사가 붙으면 이 의존과 {@link #signup} 안의 호출을 지운다. */ + private final CreditDemoProvisioner creditDemoProvisioner; + // ── 휴대폰 인증 ────────────────────────────────────────────────────────── @Transactional @@ -132,6 +136,16 @@ public SignupResponse signup(SignupRequest request) { .agreedLocation(request.isAgreedLocation()) .build()); + /* + * ⚠️ 시연용. 신용점수 이력과 비금융 납부 이력(통신요금·건강보험료·국민연금)을 + * 데모 계정에서 복제해 붙인다. 이 둘은 마이데이터 연동으로도 생기지 않아서 + * (연동은 계좌·카드만 가져온다) 가입 시점에 넣지 않으면 신용관리 화면이 빈 채로 뜬다. + * + * 마이데이터 연동을 건너뛴 회원도 화면을 볼 수 있어야 해서 연동이 아니라 가입에 건다. + * 원본 시드가 없으면 조용히 건너뛰므로 가입 자체는 실패하지 않는다. + */ + creditDemoProvisioner.provision(user.getUserId()); + return SignupResponse.builder() .userId(user.getUserId()) .accessToken(jwtTokenProvider.createAccessToken(user.getUserId())) diff --git a/backend/src/main/java/com/dday/domain/credit/repository/CreditScoreRepository.java b/backend/src/main/java/com/dday/domain/credit/repository/CreditScoreRepository.java index 076cde9..57c993e 100644 --- a/backend/src/main/java/com/dday/domain/credit/repository/CreditScoreRepository.java +++ b/backend/src/main/java/com/dday/domain/credit/repository/CreditScoreRepository.java @@ -2,6 +2,9 @@ import com.dday.domain.credit.entity.CreditScore; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -27,4 +30,30 @@ public interface CreditScoreRepository extends JpaRepository * 가장 최근 기록 한 건. 정렬 기준은 위와 같다 — 같은 시각의 기록이 섞이면 "최신"이 흔들린다. */ Optional findFirstByUserUserIdOrderByUpdatedAtDescCreditScoreIdDesc(Long userId); + + /** 이 회원에게 이력이 한 건이라도 있는지. 데모 이력을 두 번 붙이지 않으려고 본다. */ + boolean existsByUserUserId(Long userId); + + /** + * 다른 회원의 이력을 통째로 복제한다. 시연에서 누가 가입하든 같은 그래프가 보이게 하려고 + * {@code CreditDemoProvisioner}가 부른다. 새로 넣은 건수를 돌려준다. + * + *

JPA가 아니라 네이티브 SQL을 쓰는 이유는 {@code updated_at} 때문이다. + * 이 컬럼은 {@code @UpdateTimestamp}라, 엔티티로 저장하면 Hibernate가 무조건 '지금'으로 + * 덮어쓴다. 그런데 화면은 이 값을 각 기록의 월 표시로 쓰므로(신용점수 이력 표), + * 여섯 건이 전부 오늘 날짜가 되면 같은 달이 여섯 줄 뜬다. 원본의 시각을 그대로 옮기려면 + * 엔티티 생명주기를 우회하는 수밖에 없다. + * + *

{@code credit_score}에는 유일 제약이 없어 이 문장만으로는 멱등하지 않다. + * 중복 방지는 호출부의 {@link #existsByUserUserId} 검사가 맡는다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + insert into credit_score (user_id, agency, score, created_at, updated_at) + select :userId, t.agency, t.score, t.created_at, t.updated_at + from credit_score t + where t.user_id = :templateUserId + """, nativeQuery = true) + int copyHistoryFrom(@Param("userId") Long userId, + @Param("templateUserId") Long templateUserId); } diff --git a/backend/src/main/java/com/dday/domain/credit/repository/NonFinancialPaymentRepository.java b/backend/src/main/java/com/dday/domain/credit/repository/NonFinancialPaymentRepository.java index 75ca022..cda2cb3 100644 --- a/backend/src/main/java/com/dday/domain/credit/repository/NonFinancialPaymentRepository.java +++ b/backend/src/main/java/com/dday/domain/credit/repository/NonFinancialPaymentRepository.java @@ -2,6 +2,9 @@ import com.dday.domain.credit.entity.NonFinancialPayment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -15,4 +18,29 @@ public interface NonFinancialPaymentRepository extends JpaRepository { List findAllByUserUserId(Long userId); + + /** + * 다른 회원의 납부 이력을 통째로 복제한다. 시연에서 누가 가입하든 같은 납부 기록이 + * 보이게 하려고 {@code CreditDemoProvisioner}가 부른다. 새로 넣은 건수를 돌려준다. + * + *

네이티브 SQL인 이유는 청구월·납부일을 원본 그대로 옮겨야 하기 때문이다. + * {@code NonFinancialPaymentService.sync}는 '오늘'을 기준으로 12개월치를 만들어 내는데, + * 시드가 손으로 맞춰 둔 연체 위치(2026-03 통신요금)와 개월 수가 달라진다. 시연 화면을 + * 시드와 똑같이 맞추려면 만들어 내는 게 아니라 복제해야 한다. + * + *

{@code insert ignore}라 여러 번 불러도 안전하다 — + * {@code uk_nonfinancial_payment_user_type_month}가 이미 있는 달을 걸러낸다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + insert ignore into nonfinancial_payment ( + user_id, payment_type, institution_name, billing_month, amount, + due_date, paid_date, status, created_at, updated_at) + select :userId, t.payment_type, t.institution_name, t.billing_month, t.amount, + t.due_date, t.paid_date, t.status, t.created_at, t.updated_at + from nonfinancial_payment t + where t.user_id = :templateUserId + """, nativeQuery = true) + int copyHistoryFrom(@Param("userId") Long userId, + @Param("templateUserId") Long templateUserId); } diff --git a/backend/src/main/java/com/dday/domain/credit/service/CreditDemoProvisioner.java b/backend/src/main/java/com/dday/domain/credit/service/CreditDemoProvisioner.java new file mode 100644 index 0000000..9153ce7 --- /dev/null +++ b/backend/src/main/java/com/dday/domain/credit/service/CreditDemoProvisioner.java @@ -0,0 +1,90 @@ +package com.dday.domain.credit.service; + +import com.dday.domain.credit.repository.CreditScoreRepository; +import com.dday.domain.credit.repository.NonFinancialPaymentRepository; +import com.dday.domain.mockmydata.service.MockMydataProvisioner; +import com.dday.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 새로 가입한 회원에게 신용관리 데모 데이터를 붙여준다. + * + *

신용관리 화면 여섯 조각 중 넷은 마이데이터 연동이 알아서 채운다 — + * 보유 카드({@code user_card})와 대출 목록({@code user_account})은 + * {@link MockMydataProvisioner}가 복제한 목데이터를 동기화가 옮겨온 결과다. + * 나머지 둘, 신용점수 이력과 비금융 납부 이력(통신요금·건강보험료·국민연금)은 + * 어디서도 만들어지지 않아 신규 가입자에게는 빈 화면이 떴다. 이 클래스가 그 둘을 채운다. + * + *

코드에 점수·금액을 다시 적지 않고 DB의 원본을 읽어 복제하는 이유는 + * {@link MockMydataProvisioner}와 같다. 하드코딩하면 {@code data.sql}의 시드와 두 벌이 되어 + * 한쪽만 고쳤을 때 조용히 어긋난다. 데모 데이터를 바꾸고 싶으면 {@code data.sql} 한 곳만 고친다. + * + *

{@code NonFinancialPaymentService.sync}가 이미 데모 이력을 만들어 내지만 그걸 쓰지 + * 않는다. 그쪽은 '오늘'을 기준으로 12개월치를 생성해서, 시드가 손으로 맞춰 둔 개월 수(6개월)와 + * 연체 위치(2026-03 통신요금)가 달라진다. 요구는 "시드 그대로"라 생성이 아니라 복제여야 한다. + * + *

⚠️ 해커톤 시연 전용이다. 실제 신용평가사·기관이 붙으면 이 클래스와 호출부를 지운다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CreditDemoProvisioner { + + private final UserRepository userRepository; + private final CreditScoreRepository creditScoreRepository; + private final NonFinancialPaymentRepository paymentRepository; + + /** + * 이 회원의 신용관리 데모 데이터를 만든다. 여러 번 불러도 안전하다 — + * 가입 때 한 번, 마이데이터 연동 때 한 번 불리므로 두 배가 되면 안 된다. + * + *

원본이 없으면(시드를 안 넣은 DB) 조용히 건너뛴다. 데모 데이터가 없다고 해서 + * 회원가입이나 연동 자체가 실패하면 안 되기 때문이다 — + * {@link MockMydataProvisioner#provision}과 같은 방침이다. + * + * @return 실제로 한 건이라도 넣었으면 true, 건너뛰었으면 false + */ + @Transactional + public boolean provision(Long userId) { + if (userId == null) { + return false; + } + + /* + * 원본은 목데이터와 같은 데모 계정이다. 상수를 여기서 새로 선언하지 않고 + * MockMydataProvisioner 것을 그대로 쓴다 — 두 벌이 되면 한쪽만 바뀌었을 때 + * 카드·대출은 A 계정에서, 신용점수는 B 계정에서 오는 화면이 된다. + */ + Long templateUserId = userRepository.findByEmail(MockMydataProvisioner.TEMPLATE_EMAIL) + .map(user -> user.getUserId()) + .orElse(null); + if (templateUserId == null) { + log.warn("신용 데모 원본({})이 없어 복제를 건너뛴다. data.sql 시드를 확인할 것.", + MockMydataProvisioner.TEMPLATE_EMAIL); + return false; + } + if (templateUserId.equals(userId)) { + return false; // 원본 본인이면 복제할 게 없다 + } + + // 신용점수는 유일 제약이 없어 SQL만으로는 멱등하지 않다. 있으면 아예 넣지 않는다. + int scores = creditScoreRepository.existsByUserUserId(userId) + ? 0 + : creditScoreRepository.copyHistoryFrom(userId, templateUserId); + + // 납부 이력은 (user_id, payment_type, billing_month) 유일 제약 + insert ignore라 + // 그냥 다시 불러도 이미 있는 달이 걸러진다. + int payments = paymentRepository.copyHistoryFrom(userId, templateUserId); + + if (scores + payments == 0) { + return false; + } + + log.info("신용 데모 데이터를 복제했다: userId={}, 신용점수={}건, 납부이력={}건", + userId, scores, payments); + return true; + } +} diff --git a/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java b/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java index 6a5aed7..b6ebe17 100644 --- a/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java +++ b/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java @@ -1,5 +1,6 @@ package com.dday.domain.mydata.service; +import com.dday.domain.credit.service.CreditDemoProvisioner; import com.dday.domain.mockmydata.service.MockMydataProvisioner; import com.dday.domain.mydata.dto.response.MydataConnectResponse; import com.dday.domain.mydata.repository.UserAccountRepository; @@ -15,7 +16,7 @@ /** * 마이데이터 연결. 온보딩의 "기관 선택 → 연동" 화면이 부른다. * - *

네 가지를 한 번에 끝낸다. 화면에서는 버튼 하나라 왕복을 나누면 중간에 실패했을 때 + *

다섯 가지를 한 번에 끝낸다. 화면에서는 버튼 하나라 왕복을 나누면 중간에 실패했을 때 * "동의는 됐는데 데이터는 없는" 어중간한 상태가 남는다. * *

    @@ -27,13 +28,17 @@ * '자유 포켓'을 찾지 못해 404로 끝난다. 가입만으로는 포켓이 생기지 않는다. *
  1. 첫 동기화 — 목 서버의 거래를 우리 테이블로 옮긴다. 여기까지 해야 * 연동 직후 화면에 소비 내역이 뜬다.
  2. + *
  3. 신용 데모 데이터 보정 — 신용점수·비금융 납부 이력은 가입 때 붙지만 + * ({@code AuthService.signup}), 그 전에 가입한 회원과 시드로 만든 계정은 비어 있다. + * 여기서 한 번 더 불러 메운다 ({@link CreditDemoProvisioner}는 멱등하다).
  4. *
* *

클래스에 {@code @Transactional}을 붙이지 않는다. 마지막 단계가 외부 공급자 호출을 * 포함해 오래 걸리기 때문이다({@link MydataService} 주석 참고). 쓰기 원자성은 각 단계의 * 짧은 트랜잭션이 맡는다. * - *

⚠️ 2번은 해커톤 시연 전용이다. 실제 마이데이터를 붙이면 그 단계만 빼면 된다. + *

⚠️ 2번과 5번은 해커톤 시연 전용이다. 실제 마이데이터·신용평가사를 붙이면 + * 그 두 단계만 빼면 된다. */ @Slf4j @Service @@ -48,6 +53,7 @@ public class MydataConnectService { private final MydataConsentWriter consentWriter; private final MockMydataProvisioner mockMydataProvisioner; + private final CreditDemoProvisioner creditDemoProvisioner; private final MydataService mydataService; private final UserAccountRepository userAccountRepository; private final UserCardRepository userCardRepository; @@ -69,7 +75,12 @@ public MydataConnectResponse connect(Long userId) { MydataSyncResponse sync = mydataService.sync(userId, null, null); - log.info("마이데이터 연결: userId={}, 목데이터 복제={}", userId, provisioned); + // 가입 때 이미 붙었으면 아무 일도 하지 않는다. 이 호출은 그 전에 가입한 회원과 + // 시드로 만든 계정처럼 신용 이력이 비어 있는 경우를 메우려고 남겨둔다. + boolean creditProvisioned = creditDemoProvisioner.provision(userId); + + log.info("마이데이터 연결: userId={}, 목데이터 복제={}, 신용 데모 복제={}", + userId, provisioned, creditProvisioned); // 동기화가 끝난 뒤 읽어야 이번에 새로 붙은 계좌까지 응답에 담긴다. // 비활성 계좌·카드는 뺀다 — 기관을 정리한 뒤에도 예전에 연동했던 회원은 죽은 diff --git a/backend/src/test/java/com/dday/domain/auth/service/AuthServiceTest.java b/backend/src/test/java/com/dday/domain/auth/service/AuthServiceTest.java index 11088d6..d05fa6d 100644 --- a/backend/src/test/java/com/dday/domain/auth/service/AuthServiceTest.java +++ b/backend/src/test/java/com/dday/domain/auth/service/AuthServiceTest.java @@ -5,6 +5,7 @@ import com.dday.domain.auth.entity.PhoneVerification; import com.dday.domain.auth.entity.VerificationPurpose; import com.dday.domain.auth.repository.PhoneVerificationRepository; +import com.dday.domain.credit.service.CreditDemoProvisioner; import com.dday.domain.user.entity.User; import com.dday.domain.user.repository.UserRepository; import com.dday.global.common.jwt.JwtTokenProvider; @@ -49,6 +50,8 @@ class AuthServiceTest { private PhoneVerificationRepository phoneVerificationRepository; @Mock private JwtTokenProvider jwtTokenProvider; + @Mock + private CreditDemoProvisioner creditDemoProvisioner; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @@ -56,8 +59,8 @@ class AuthServiceTest { @BeforeEach void setUp() { - authService = new AuthService( - userRepository, phoneVerificationRepository, passwordEncoder, jwtTokenProvider); + authService = new AuthService(userRepository, phoneVerificationRepository, passwordEncoder, + jwtTokenProvider, creditDemoProvisioner); given(userRepository.existsByEmail(any())).willReturn(false); given(userRepository.save(any())).willAnswer(inv -> { @@ -110,6 +113,31 @@ private static ErrorCode errorCodeOf(Throwable e) { assertThat(response.getAccessToken()).isEqualTo("access"); } + /** + * 신용관리 화면(신용점수 이력·납부 이력)은 가입 시점에 채워지지 않으면 빈 채로 뜬다. + * 마이데이터 연동은 계좌·카드만 가져오므로 그쪽에 기댈 수 없다. + */ + @Test + void 가입하면_신용_데모_데이터가_붙는다() { + givenVerification(VerificationPurpose.SIGNUP, LocalDateTime.now().minusMinutes(5)); + + authService.signup(signupRequest()); + + verify(creditDemoProvisioner).provision(1L); + } + + @Test + void 휴대폰_인증에_실패하면_신용_데모_데이터를_붙이지_않는다() { + given(phoneVerificationRepository + .findTopByPhoneAndPurposeAndVerifiedTrueOrderByVerificationIdDesc(any(), any())) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.signup(signupRequest())) + .isInstanceOf(BusinessException.class); + + verify(creditDemoProvisioner, never()).provision(any()); + } + @Test void 휴대폰_인증_없이는_가입할_수_없다() { // 발송·검증 기록이 아예 없는 상태 — signup API를 직접 호출한 경우다. diff --git a/backend/src/test/java/com/dday/domain/credit/service/CreditDemoProvisionerTest.java b/backend/src/test/java/com/dday/domain/credit/service/CreditDemoProvisionerTest.java new file mode 100644 index 0000000..ee15815 --- /dev/null +++ b/backend/src/test/java/com/dday/domain/credit/service/CreditDemoProvisionerTest.java @@ -0,0 +1,105 @@ +package com.dday.domain.credit.service; + +import com.dday.domain.credit.repository.CreditScoreRepository; +import com.dday.domain.credit.repository.NonFinancialPaymentRepository; +import com.dday.domain.mockmydata.service.MockMydataProvisioner; +import com.dday.domain.user.entity.User; +import com.dday.domain.user.repository.UserRepository; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 데모 계정의 신용 이력을 신규 가입자에게 복제하는 규칙을 확인한다. + * + *

가입과 마이데이터 연동 양쪽에서 불리므로 두 번 불려도 이력이 두 배가 되지 않는 것이 + * 핵심이다. 신용점수는 유일 제약이 없어 SQL이 막아주지 못하고 이 클래스의 검사에 달려 있다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CreditDemoProvisionerTest { + + private static final Long TEMPLATE_USER_ID = 9001L; + private static final Long TARGET_USER_ID = 42L; + + @Mock + private UserRepository userRepository; + @Mock + private CreditScoreRepository creditScoreRepository; + @Mock + private NonFinancialPaymentRepository paymentRepository; + + @InjectMocks + private CreditDemoProvisioner provisioner; + + private void givenTemplate() { + User template = User.builder().email(MockMydataProvisioner.TEMPLATE_EMAIL).build(); + ReflectionTestUtils.setField(template, "userId", TEMPLATE_USER_ID); + given(userRepository.findByEmail(MockMydataProvisioner.TEMPLATE_EMAIL)) + .willReturn(Optional.of(template)); + } + + @Test + void 데모_계정의_신용점수와_납부이력을_복제한다() { + givenTemplate(); + given(creditScoreRepository.existsByUserUserId(TARGET_USER_ID)).willReturn(false); + given(creditScoreRepository.copyHistoryFrom(TARGET_USER_ID, TEMPLATE_USER_ID)).willReturn(6); + given(paymentRepository.copyHistoryFrom(TARGET_USER_ID, TEMPLATE_USER_ID)).willReturn(18); + + boolean provisioned = provisioner.provision(TARGET_USER_ID); + + assertThat(provisioned).isTrue(); + verify(creditScoreRepository).copyHistoryFrom(TARGET_USER_ID, TEMPLATE_USER_ID); + verify(paymentRepository).copyHistoryFrom(TARGET_USER_ID, TEMPLATE_USER_ID); + } + + @Test + void 이미_신용점수_이력이_있으면_다시_넣지_않는다() { + givenTemplate(); + given(creditScoreRepository.existsByUserUserId(TARGET_USER_ID)).willReturn(true); + given(paymentRepository.copyHistoryFrom(TARGET_USER_ID, TEMPLATE_USER_ID)).willReturn(0); + + boolean provisioned = provisioner.provision(TARGET_USER_ID); + + assertThat(provisioned).isFalse(); + verify(creditScoreRepository, never()).copyHistoryFrom(anyLong(), anyLong()); + } + + /** 시드를 안 넣은 DB에서도 가입·연동이 깨지면 안 된다. */ + @Test + void 원본_계정이_없으면_조용히_건너뛴다() { + given(userRepository.findByEmail(any())).willReturn(Optional.empty()); + + boolean provisioned = provisioner.provision(TARGET_USER_ID); + + assertThat(provisioned).isFalse(); + verify(creditScoreRepository, never()).copyHistoryFrom(anyLong(), anyLong()); + verify(paymentRepository, never()).copyHistoryFrom(anyLong(), anyLong()); + } + + /** 원본 본인이 다시 복제하면 자기 이력이 두 배가 된다. */ + @Test + void 원본_계정_본인에게는_붙이지_않는다() { + givenTemplate(); + + boolean provisioned = provisioner.provision(TEMPLATE_USER_ID); + + assertThat(provisioned).isFalse(); + verify(creditScoreRepository, never()).copyHistoryFrom(anyLong(), anyLong()); + verify(paymentRepository, never()).copyHistoryFrom(anyLong(), anyLong()); + } +}