From e99bb8096525d27e0fd4b203930a26e752e5968e Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 09:46:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20account-service=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=A4=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/VirtualFinancialDataService.java | 72 ++++++++++++++----- .../VirtualFinancialTransactionGenerator.java | 18 ++++- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataService.java b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataService.java index 93156a69..5ec88cde 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataService.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataService.java @@ -6,6 +6,7 @@ import java.time.YearMonth; import java.util.List; import java.util.Locale; +import java.util.function.BiFunction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,10 @@ import com.ntropy.account.mapper.AccountMapper; import com.ntropy.account.mapper.AccountTransactionMapper; import com.ntropy.account.service.VirtualFinancialTransactionGenerator.GeneratedTransactions; +import com.ntropy.common.client.VirtualUserQueryClient; +import com.ntropy.common.dto.user.VirtualDatasetContext; +import com.ntropy.common.dto.user.VirtualUserDataset; +import com.ntropy.common.dto.user.VirtualUserIdentity; import lombok.RequiredArgsConstructor; @@ -33,7 +38,6 @@ public class VirtualFinancialDataService { public static final int ACCOUNTS_PER_USER = 2; public static final int EXPECTED_TRANSACTION_COUNT = 15_000; - private static final long LOGICAL_USER_ID_BASE = 9_000_046_000L; private static final String DATASET_VERSION = "FIN-005-v1"; private static final String CURRENCY_KRW = "KRW"; private static final int TRANSACTION_BATCH_SIZE = 500; @@ -43,25 +47,47 @@ public class VirtualFinancialDataService { private final AccountTransactionMapper accountTransactionMapper; private final VirtualFinancialTransactionGenerator transactionGenerator; private final Clock clock; + private final VirtualUserQueryClient virtualUserQueryClient; + /** user-service가 시딩한 실제 가상회원 목록을 조회해 이 서비스의 생성기를 호출하는 조합 지점. */ @Transactional public GenerationSummary generate() { - LocalDate referenceDate = LocalDate.now(clock); + VirtualUserDataset dataset = virtualUserQueryClient.findSeededVirtualUsers(); + return generateForUsers(dataset.users(), dataset.context()); + } + + /** 실제 USERS.user_id 목록과 실행 컨텍스트를 받아 가상 금융 데이터셋을 멱등하게 생성한다. */ + @Transactional + public GenerationSummary generateForUsers(List users, VirtualDatasetContext context) { + if (users == null || users.isEmpty()) { + throw new IllegalArgumentException("가상회원 목록이 필요합니다"); + } + if (context == null) { + throw new IllegalArgumentException("데이터셋 실행 컨텍스트가 필요합니다"); + } + LocalDate referenceDate = context.referenceDate(); + int totalUsers = users.size(); int generatedAccounts = 0; int generatedTransactions = 0; PersonalBank[] banks = PersonalBank.values(); - for (int userOrdinal = 1; userOrdinal <= USER_COUNT; userOrdinal++) { - Long userId = LOGICAL_USER_ID_BASE + userOrdinal; - PersonalBank bank = banks[(userOrdinal - 1) % banks.length]; - UserGenerationResult result = generateForUser(userId, bank, userOrdinal, false, referenceDate); + for (VirtualUserIdentity identity : users) { + int userOrdinal = identity.ordinal(); + PersonalBank bank = bankFor(userOrdinal, context.randomSeed(), banks); + UserGenerationResult result = generateAccountsAndTransactions( + identity.userId(), bank, userOrdinal, totalUsers, referenceDate, + (ordinaryAccount, secondaryAccount) -> transactionGenerator.generate( + referenceDate, userOrdinal, context.randomSeed(), bank, + ordinaryAccount, secondaryAccount + ) + ); generatedAccounts += result.accounts(); generatedTransactions += result.transactions(); } // 지난 2개월은 사용자당 200건 고정이고, 현재 월은 기준일까지만 생성돼 사용자당 0~100건이다. - int minimumExpected = USER_COUNT * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 2; - int maximumExpected = USER_COUNT * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 3; + int minimumExpected = totalUsers * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 2; + int maximumExpected = totalUsers * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 3; if (generatedTransactions < minimumExpected || generatedTransactions > maximumExpected) { throw new IllegalStateException( "가상 거래 총 건수 불일치: expected=[" + minimumExpected + "," + maximumExpected + "]" @@ -69,7 +95,7 @@ public GenerationSummary generate() { ); } return new GenerationSummary( - USER_COUNT, generatedAccounts, VirtualFinancialTransactionGenerator.INCOME_COUNTERPARTY_COUNT, + totalUsers, generatedAccounts, VirtualFinancialTransactionGenerator.INCOME_COUNTERPARTY_COUNT, generatedTransactions, YearMonth.from(referenceDate).minusMonths(2).atDay(1), referenceDate @@ -87,7 +113,12 @@ public GenerationSummary generateForUser(Long userId, PersonalBank bank) { } LocalDate referenceDate = LocalDate.now(clock); int ordinal = stableUserOrdinal(userId); - UserGenerationResult result = generateForUser(userId, bank, ordinal, true, referenceDate); + UserGenerationResult result = generateAccountsAndTransactions( + userId, bank, ordinal, USER_COUNT, referenceDate, + (ordinaryAccount, secondaryAccount) -> transactionGenerator.generateForUser( + referenceDate, userId, bank, ordinaryAccount, secondaryAccount + ) + ); return new GenerationSummary( 1, result.accounts(), result.incomeCounterparties(), result.transactions(), YearMonth.from(referenceDate).minusMonths(2).atDay(1), @@ -95,8 +126,9 @@ public GenerationSummary generateForUser(Long userId, PersonalBank bank) { ); } - private UserGenerationResult generateForUser(Long userId, PersonalBank bank, int userOrdinal, - boolean useUserIdProfile, LocalDate referenceDate) { + private UserGenerationResult generateAccountsAndTransactions( + Long userId, PersonalBank bank, int userOrdinal, int totalUsers, LocalDate referenceDate, + BiFunction transactionsSupplier) { CodefConnection connection = virtualConnectionService.getOrCreateConnection(userId); virtualConnectionService.registerInstitution(connection, bank.getOrganizationCode()); @@ -104,11 +136,9 @@ private UserGenerationResult generateForUser(Long userId, PersonalBank bank, int buildOrdinaryAccount(userOrdinal, userId, connection, bank, referenceDate) ); Account secondaryAccount = saveAccount( - buildSecondaryAccount(userOrdinal, userId, connection, bank, referenceDate) + buildSecondaryAccount(userOrdinal, totalUsers, userId, connection, bank, referenceDate) ); - GeneratedTransactions generated = useUserIdProfile - ? transactionGenerator.generateForUser(referenceDate, userId, bank, ordinaryAccount, secondaryAccount) - : transactionGenerator.generate(referenceDate, userOrdinal, bank, ordinaryAccount, secondaryAccount); + GeneratedTransactions generated = transactionsSupplier.apply(ordinaryAccount, secondaryAccount); ordinaryAccount.setBalance(generated.finalBalances().get(ordinaryAccount.getId())); secondaryAccount.setBalance(generated.finalBalances().get(secondaryAccount.getId())); @@ -123,6 +153,12 @@ private UserGenerationResult generateForUser(Long userId, PersonalBank bank, int return new UserGenerationResult(2, generated.transactions().size(), generated.userIncomeCounterpartyCount()); } + /** 은행 배정: 순번을 randomSeed만큼 순환시켜, 데이터셋 버전(랜덤시드)이 바뀌면 배정도 함께 바뀌도록 한다. */ + private static PersonalBank bankFor(int userOrdinal, long randomSeed, PersonalBank[] banks) { + int index = (int) Math.floorMod((userOrdinal - 1L) + randomSeed, (long) banks.length); + return banks[index]; + } + private static int stableUserOrdinal(Long userId) { int profileCount = VirtualFinancialTransactionGenerator.ConsumerProfile.values().length; int hash = Long.hashCode(userId); @@ -155,11 +191,11 @@ private static Account buildOrdinaryAccount(int userOrdinal, Long userId, return account; } - private static Account buildSecondaryAccount(int userOrdinal, Long userId, + private static Account buildSecondaryAccount(int userOrdinal, int totalUsers, Long userId, CodefConnection connection, PersonalBank bank, LocalDate referenceDate) { Account account = baseAccount(userOrdinal, userId, connection, bank, 2, referenceDate); - boolean installment = userOrdinal <= USER_COUNT / 2; + boolean installment = userOrdinal <= totalUsers / 2; account.setAccountGroup(installment ? AccountGroup.DEPOSIT_TRUST : AccountGroup.LOAN); account.setDepositTypeCode(installment ? "12" : "40"); account.setAccountName(bank.getDisplayName() + (installment ? " 가상 적금" : " 가상 대출")); diff --git a/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialTransactionGenerator.java b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialTransactionGenerator.java index cc0f02f1..477b2dfc 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialTransactionGenerator.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialTransactionGenerator.java @@ -142,8 +142,15 @@ public class VirtualFinancialTransactionGenerator { public GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal, PersonalBank bank, Account ordinaryAccount, Account secondaryAccount) { + return generate(referenceDate, userOrdinal, 0L, bank, ordinaryAccount, secondaryAccount); + } + + /** 공용 데이터셋 seed와 사용자 순번으로 소비 프로필을 결정해 거래를 생성한다. */ + public GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal, long randomSeed, + PersonalBank bank, Account ordinaryAccount, Account secondaryAccount) { return generate( - referenceDate, userOrdinal, consumerProfileFor(userOrdinal), bank, ordinaryAccount, secondaryAccount + referenceDate, userOrdinal, consumerProfileFor(userOrdinal, randomSeed), + bank, ordinaryAccount, secondaryAccount ); } @@ -168,7 +175,7 @@ private GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal, if (referenceDate == null) { throw new IllegalArgumentException("기준일이 필요합니다"); } - if (userOrdinal < 1 || userOrdinal > VirtualFinancialDataService.USER_COUNT) { + if (userOrdinal < 1) { throw new IllegalArgumentException("가상 사용자 순번이 범위를 벗어났습니다: " + userOrdinal); } @@ -681,8 +688,13 @@ private static boolean isTransfer(PlannedTransaction plan) { } static ConsumerProfile consumerProfileFor(int userOrdinal) { + return consumerProfileFor(userOrdinal, 0L); + } + + static ConsumerProfile consumerProfileFor(int userOrdinal, long randomSeed) { ConsumerProfile[] profiles = ConsumerProfile.values(); - return profiles[Math.floorMod(userOrdinal - 1, profiles.length)]; + int index = (int) Math.floorMod((userOrdinal - 1L) + randomSeed, (long) profiles.length); + return profiles[index]; } static ConsumerProfile consumerProfileForUser(Long userId) { From aa923b322d43e7f17fa2f388edf74b9078aa85af Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 09:46:14 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20VirtualFinancialDataService=20?= =?UTF-8?q?=EC=8B=A4=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...VirtualAccountRegenerationServiceTest.java | 4 +- .../VirtualFinancialDataServiceTest.java | 69 ++++++++++++++++--- ...tualFinancialTransactionGeneratorTest.java | 16 +++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/services/account-service/src/test/java/com/ntropy/account/service/VirtualAccountRegenerationServiceTest.java b/services/account-service/src/test/java/com/ntropy/account/service/VirtualAccountRegenerationServiceTest.java index 97c5b5aa..659a7d10 100644 --- a/services/account-service/src/test/java/com/ntropy/account/service/VirtualAccountRegenerationServiceTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/service/VirtualAccountRegenerationServiceTest.java @@ -39,7 +39,7 @@ void replacesOnlyTargetUsersNtropyDataWithoutDuplicatesAndKeepsCodefData() { Clock clock = Clock.fixed(LocalDate.of(2026, 6, 30).atStartOfDay(zone).toInstant(), zone); VirtualFinancialDataService dataService = new VirtualFinancialDataService( new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, - new VirtualFinancialTransactionGenerator(), clock + new VirtualFinancialTransactionGenerator(), clock, null ); VirtualAccountRegenerationService regenerationService = new VirtualAccountRegenerationService(accountMapper, transactionMapper, dataService); @@ -115,7 +115,7 @@ void replacesDataWithoutDuplicatesWhenRegisteringSameBankAgain() { Clock clock = Clock.fixed(LocalDate.of(2026, 6, 30).atStartOfDay(zone).toInstant(), zone); VirtualFinancialDataService dataService = new VirtualFinancialDataService( new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, - new VirtualFinancialTransactionGenerator(), clock + new VirtualFinancialTransactionGenerator(), clock, null ); VirtualAccountRegenerationService regenerationService = new VirtualAccountRegenerationService(accountMapper, transactionMapper, dataService); diff --git a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataServiceTest.java b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataServiceTest.java index 70b1aabb..7a9d19a3 100644 --- a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataServiceTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataServiceTest.java @@ -22,9 +22,26 @@ import com.ntropy.account.mapper.AccountTransactionMapper; import com.ntropy.account.mapper.CodefConnectionMapper; import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary; +import com.ntropy.common.client.VirtualUserQueryClient; +import com.ntropy.common.dto.user.VirtualDatasetContext; +import com.ntropy.common.dto.user.VirtualUserDataset; +import com.ntropy.common.dto.user.VirtualUserIdentity; class VirtualFinancialDataServiceTest { + // 실제 USERS.user_id는 AUTO_INCREMENT 값이라 순번과 무관하다는 것을 드러내기 위해 순번과 다른 배수로 잡는다. + private static long userIdFor(int ordinal) { + return 8_000_000_000L + ordinal * 3L; + } + + private static List fiftyUserDataset() { + List users = new ArrayList<>(); + for (int ordinal = 1; ordinal <= 50; ordinal++) { + users.add(new VirtualUserIdentity(userIdFor(ordinal), ordinal)); + } + return users; + } + @Test void generatesExpectedDatasetAndIsIdempotent() { InMemoryCodefConnectionMapper connectionMapper = new InMemoryCodefConnectionMapper(); @@ -32,17 +49,22 @@ void generatesExpectedDatasetAndIsIdempotent() { InMemoryAccountTransactionMapper transactionMapper = new InMemoryAccountTransactionMapper(); ZoneId zone = ZoneId.of("Asia/Seoul"); // 월말을 기준일로 고정해 "현재 월"이 항상 완결된 3개월 창이 되도록 하고, 기존 고정 건수 검증값을 그대로 유지한다. - Clock clock = Clock.fixed(LocalDate.of(2026, 6, 30).atStartOfDay(zone).toInstant(), zone); + Clock clock = Clock.fixed(LocalDate.of(2020, 1, 1).atStartOfDay(zone).toInstant(), zone); VirtualFinancialDataService service = new VirtualFinancialDataService( new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, new VirtualFinancialTransactionGenerator(), - clock + clock, + null // generateForUsers()를 직접 호출하므로 VirtualUserQueryClient는 쓰이지 않는다. + ); + List users = fiftyUserDataset(); + VirtualDatasetContext context = new VirtualDatasetContext( + "FIN-005-test-v1", LocalDate.of(2026, 6, 30), 1L ); - GenerationSummary first = service.generate(); - GenerationSummary second = service.generate(); + GenerationSummary first = service.generateForUsers(users, context); + GenerationSummary second = service.generateForUsers(users, context); assertEquals(50, first.users()); assertEquals(100, first.accounts()); @@ -55,7 +77,7 @@ void generatesExpectedDatasetAndIsIdempotent() { // 사용자 순번 1은 적금(installment), 26은 대출(loan) 보조계좌를 받는다. Account installmentAccount = accountMapper.store.values().stream() - .filter(account -> account.getUserId() == 9_000_046_001L + .filter(account -> account.getUserId() == userIdFor(1) && account.getAccountGroup() == com.ntropy.account.domain.AccountGroup.DEPOSIT_TRUST && "12".equals(account.getDepositTypeCode())) .findFirst().orElseThrow(); @@ -66,7 +88,7 @@ void generatesExpectedDatasetAndIsIdempotent() { assertEquals(lastTransactionDate(transactionMapper, installmentAccount), installmentAccount.getLastTranDate()); Account loanAccount = accountMapper.store.values().stream() - .filter(account -> account.getUserId() == 9_000_046_026L + .filter(account -> account.getUserId() == userIdFor(26) && account.getAccountGroup() == com.ntropy.account.domain.AccountGroup.LOAN) .findFirst().orElseThrow(); assertEquals(java.math.BigDecimal.valueOf(80_000_000L), loanAccount.getLoanContractPrincipal()); @@ -76,6 +98,37 @@ void generatesExpectedDatasetAndIsIdempotent() { assertEquals(lastTransactionDate(transactionMapper, loanAccount), loanAccount.getLastTranDate()); } + @Test + void generateDelegatesToVirtualUserQueryClientDatasetUsingContextReferenceDate() { + InMemoryCodefConnectionMapper connectionMapper = new InMemoryCodefConnectionMapper(); + InMemoryAccountMapper accountMapper = new InMemoryAccountMapper(); + InMemoryAccountTransactionMapper transactionMapper = new InMemoryAccountTransactionMapper(); + ZoneId zone = ZoneId.of("Asia/Seoul"); + // Clock은 generate()/generateForUsers() 경로에서 쓰이지 않아야 하므로, context와 다른 날짜로 고정해 둔다. + Clock clock = Clock.fixed(LocalDate.of(2099, 1, 1).atStartOfDay(zone).toInstant(), zone); + List users = List.of( + new VirtualUserIdentity(7_000_000_001L, 1), + new VirtualUserIdentity(7_000_000_002L, 2), + new VirtualUserIdentity(7_000_000_003L, 3), + new VirtualUserIdentity(7_000_000_004L, 4) + ); + VirtualDatasetContext context = new VirtualDatasetContext( + "FIN-005-test-v2", LocalDate.of(2026, 6, 30), 42L + ); + VirtualUserQueryClient stubClient = () -> new VirtualUserDataset(context, users); + VirtualFinancialDataService service = new VirtualFinancialDataService( + new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, + new VirtualFinancialTransactionGenerator(), clock, stubClient + ); + + GenerationSummary summary = service.generate(); + + assertEquals(4, summary.users()); + assertEquals(8, summary.accounts()); + assertEquals(context.referenceDate(), summary.endDate()); + assertEquals(1_200, summary.transactions()); + } + @Test void handlesLeapYearReferenceDateForProductConditionDatesWithoutError() { InMemoryCodefConnectionMapper connectionMapper = new InMemoryCodefConnectionMapper(); @@ -86,7 +139,7 @@ void handlesLeapYearReferenceDateForProductConditionDatesWithoutError() { Clock clock = Clock.fixed(LocalDate.of(2028, 2, 29).atStartOfDay(zone).toInstant(), zone); VirtualFinancialDataService service = new VirtualFinancialDataService( new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, - new VirtualFinancialTransactionGenerator(), clock + new VirtualFinancialTransactionGenerator(), clock, null ); assertDoesNotThrow(() -> service.generateForUser(9_000_046_001L, com.ntropy.account.domain.PersonalBank.NH_BANK)); @@ -107,7 +160,7 @@ void usesCurrentMonthTwentyFifthBeforeDefaultPaymentDay() { Clock clock = Clock.fixed(LocalDate.of(2026, 6, 15).atStartOfDay(zone).toInstant(), zone); VirtualFinancialDataService service = new VirtualFinancialDataService( new VirtualConnectionService(connectionMapper), accountMapper, transactionMapper, - new VirtualFinancialTransactionGenerator(), clock + new VirtualFinancialTransactionGenerator(), clock, null ); service.generateForUser(9_000_046_001L, com.ntropy.account.domain.PersonalBank.NH_BANK); diff --git a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialTransactionGeneratorTest.java b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialTransactionGeneratorTest.java index 85504966..fbbb0e73 100644 --- a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialTransactionGeneratorTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialTransactionGeneratorTest.java @@ -181,6 +181,22 @@ void appliesConsumerProfileToMerchantMixAndSpendingLevel() { assertTrue(averageCardSpending(trend) > averageCardSpending(rational)); } + @Test + void appliesDatasetRandomSeedToConsumerProfile() { + assertEquals(VirtualFinancialTransactionGenerator.ConsumerProfile.RATIONAL_FRUGAL, + VirtualFinancialTransactionGenerator.consumerProfileFor(1, 0L)); + assertEquals(VirtualFinancialTransactionGenerator.ConsumerProfile.TREND_STATUS, + VirtualFinancialTransactionGenerator.consumerProfileFor(1, 1L)); + assertEquals(VirtualFinancialTransactionGenerator.ConsumerProfile.IMPULSE_INDIFFERENT, + VirtualFinancialTransactionGenerator.consumerProfileFor(1, -1L)); + + GeneratedTransactions seeded = generator.generate( + REFERENCE_DATE, 1, 1L, PersonalBank.SHINHAN_BANK, + account(58L, AccountGroup.DEPOSIT_TRUST), account(59L, AccountGroup.DEPOSIT_TRUST) + ); + assertTrue(hasMerchant(seeded, "신세계백화점")); + } + @Test void assignsLoggedInUserProfileByStableUserIdHash() { for (long userId = 1; userId <= 20; userId++) { From 45f92fa179c3d2b3ee3a4b4bc20c1d8c01c04d88 Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 09:46:18 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=EA=B0=80=EC=83=81=20=EA=B8=88?= =?UTF-8?q?=EC=9C=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20USE?= =?UTF-8?q?RS=20=EC=8B=9C=EB=94=A9=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...untRegenerationManualVerificationTest.java | 16 +- ...alFinancialDataManualVerificationTest.java | 176 ++++++++++++------ 2 files changed, 132 insertions(+), 60 deletions(-) diff --git a/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualAccountRegenerationManualVerificationTest.java b/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualAccountRegenerationManualVerificationTest.java index 8f717219..4a220c96 100644 --- a/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualAccountRegenerationManualVerificationTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualAccountRegenerationManualVerificationTest.java @@ -29,6 +29,7 @@ import com.ntropy.account.service.VirtualFinancialDataService; import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary; import com.ntropy.account.service.VirtualFinancialTransactionGenerator; +import com.ntropy.common.client.VirtualUserQueryClient; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -171,16 +172,27 @@ Clock clock() { return Clock.systemDefaultZone(); } + @Bean + VirtualUserQueryClient virtualUserQueryClient() { + // 이 테스트는 generateForUser(userId, bank)만 사용하므로 실제로 호출되지 않는다. + return () -> { + throw new UnsupportedOperationException( + "이 수동 검증 테스트는 배치 시딩 경로를 사용하지 않습니다" + ); + }; + } + @Bean VirtualFinancialDataService virtualFinancialDataService( VirtualConnectionService connectionService, AccountMapper accountMapper, AccountTransactionMapper transactionMapper, VirtualFinancialTransactionGenerator generator, - Clock clock + Clock clock, + VirtualUserQueryClient virtualUserQueryClient ) { return new VirtualFinancialDataService( - connectionService, accountMapper, transactionMapper, generator, clock + connectionService, accountMapper, transactionMapper, generator, clock, virtualUserQueryClient ); } diff --git a/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualFinancialDataManualVerificationTest.java b/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualFinancialDataManualVerificationTest.java index ee5f3beb..90c6566a 100644 --- a/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualFinancialDataManualVerificationTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/integration/virtual/VirtualFinancialDataManualVerificationTest.java @@ -1,11 +1,15 @@ package com.ntropy.account.integration.virtual; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.time.Clock; import java.time.LocalDate; -import java.time.ZoneId; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import javax.sql.DataSource; @@ -27,14 +31,25 @@ import com.ntropy.account.service.VirtualFinancialDataService; import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary; import com.ntropy.account.service.VirtualFinancialTransactionGenerator; +import com.ntropy.common.client.VirtualUserQueryClient; +import com.ntropy.common.dto.user.VirtualDatasetContext; +import com.ntropy.common.dto.user.VirtualUserDataset; +import com.ntropy.common.dto.user.VirtualUserIdentity; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; -/** RUN_VIRTUAL_FINANCIAL_DATA_TEST=true일 때 로컬 MySQL에 FIN-005 목데이터를 적재한다. */ +/** + * RUN_VIRTUAL_FINANCIAL_DATA_TEST=true일 때 로컬 MySQL에 FIN-005 목데이터를 적재한다. + * user-service가 먼저 기동돼 USERS 테이블에 provider=NTROPY_TEST 가상회원을 시딩해 둔 상태여야 한다 + * (VirtualUserSeedInitializer, 이슈 #167). account-service 모듈은 user-service에 컴파일 의존성이 + * 없으므로, 이 테스트는 USERS 테이블을 직접 조회하는 {@link VirtualUserQueryClient} 구현을 둔다. + */ class VirtualFinancialDataManualVerificationTest { - private static final long FIRST_USER_ID = 9_000_046_001L; - private static final long LAST_USER_ID = 9_000_046_050L; + private static final String VIRTUAL_TEST_PROVIDER = "NTROPY_TEST"; + private static final VirtualDatasetContext MANUAL_DATASET_CONTEXT = new VirtualDatasetContext( + "VIRTUAL-MULTI-DOMAIN-v1", LocalDate.of(2026, 8, 17), 20_260_817L + ); @Test void generatesIdempotentVirtualFinancialDataset() { @@ -46,86 +61,97 @@ void generatesIdempotentVirtualFinancialDataset() { try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfig.class)) { VirtualFinancialDataService service = context.getBean(VirtualFinancialDataService.class); + VirtualUserQueryClient virtualUserQueryClient = context.getBean(VirtualUserQueryClient.class); + JdbcTemplate jdbc = new JdbcTemplate(context.getBean(DataSource.class)); + + VirtualUserDataset dataset = virtualUserQueryClient.findSeededVirtualUsers(); + assumeTrue(!dataset.users().isEmpty(), + "user-service가 먼저 가상회원을 시딩해 둬야 합니다 (provider=NTROPY_TEST)"); + + int userCount = dataset.users().size(); + Set expectedUserIds = dataset.users().stream() + .map(VirtualUserIdentity::userId) + .collect(Collectors.toSet()); + GenerationSummary first = service.generate(); GenerationSummary second = service.generate(); - JdbcTemplate jdbc = new JdbcTemplate(context.getBean(DataSource.class)); assertEquals(first, second); - assertEquals(50, count(jdbc, """ - SELECT COUNT(*) FROM CODEF_CONNECTION - WHERE provider = 'NTROPY' AND user_id BETWEEN ? AND ? - """)); - assertEquals(100, count(jdbc, """ - SELECT COUNT(*) FROM ACCOUNT - WHERE user_id BETWEEN ? AND ? - """)); - assertEquals(15_000, count(jdbc, """ + assertEquals(expectedUserIds.size(), userCount, + "가상회원 userId는 중복이 없어야 합니다"); + Set storedAccountUserIds = Set.copyOf(jdbc.queryForList(""" + SELECT DISTINCT account_row.user_id + FROM ACCOUNT account_row + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? + """, Long.class, VIRTUAL_TEST_PROVIDER)); + assertEquals(expectedUserIds, storedAccountUserIds, + "생성된 모든 Account.userId는 조회한 가상회원 userId 집합과 정확히 일치해야 합니다"); + assertEquals(userCount, count(jdbc, """ + SELECT COUNT(*) + FROM CODEF_CONNECTION connection_row + JOIN USERS seeded_user ON seeded_user.user_id = connection_row.user_id + WHERE connection_row.provider = 'NTROPY' + AND seeded_user.provider = ? + """, VIRTUAL_TEST_PROVIDER)); + assertEquals(userCount * 2, count(jdbc, """ + SELECT COUNT(*) + FROM ACCOUNT account_row + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? + """, VIRTUAL_TEST_PROVIDER)); + assertEquals(first.transactions(), count(jdbc, """ SELECT COUNT(*) FROM ACCOUNT_TRANSACTION transaction_row JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? - """)); - assertEquals(15, count(jdbc, """ - SELECT COUNT(DISTINCT REPLACE(transaction_row.desc3, '홈)', '')) - FROM ACCOUNT_TRANSACTION transaction_row - JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? - AND REPLACE(transaction_row.desc3, '홈)', '') IN ( - '우아한청년들', '쿠팡이츠정산', '위대한상상', '카카오모빌리티', 'GOOGLE', - '쿠팡-용역비', '티맵모빌리티', 'CJ대한통운', '로젠택배', '생활연구소', - '유한회사미소', '케어닥', '펫피플', 'PAYPAL', '네이버' - ) - """)); + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? + """, VIRTUAL_TEST_PROVIDER)); assertEquals(0, count(jdbc, """ SELECT COUNT(*) FROM ( SELECT transaction_row.account_id, transaction_row.fingerprint FROM ACCOUNT_TRANSACTION transaction_row JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? GROUP BY transaction_row.account_id, transaction_row.fingerprint HAVING COUNT(*) > 1 ) duplicate_fingerprint - """)); + """, VIRTUAL_TEST_PROVIDER)); assertEquals(0, count(jdbc, """ SELECT COUNT(*) FROM ACCOUNT_TRANSACTION transaction_row JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? AND transaction_row.desc2 IN ('입금', '출금') - """)); - assertEquals(180, count(jdbc, """ - SELECT COUNT(*) + """, VIRTUAL_TEST_PROVIDER)); + int distinctIncomeCounterparties = count(jdbc, """ + SELECT COUNT(DISTINCT REPLACE(transaction_row.desc3, '홈)', '')) FROM ACCOUNT_TRANSACTION transaction_row JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? - AND account_row.organization_code = '0003' - AND transaction_row.desc3 IN ( + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? + AND REPLACE(transaction_row.desc3, '홈)', '') IN ( '우아한청년들', '쿠팡이츠정산', '위대한상상', '카카오모빌리티', 'GOOGLE', '쿠팡-용역비', '티맵모빌리티', 'CJ대한통운', '로젠택배', '생활연구소', '유한회사미소', '케어닥', '펫피플', 'PAYPAL', '네이버' ) - AND transaction_row.desc1 IS NOT NULL - """)); - assertEquals(225, count(jdbc, """ - SELECT COUNT(*) - FROM ACCOUNT_TRANSACTION transaction_row - JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? - AND REPLACE(transaction_row.desc3, '홈)', '') IN ( - '삼성생명 실손보험', '현대해상 건강보험', 'DB손해보험 운전자보험', - 'KB손해보험 암보험', '교보생명 종신보험', '한화생명 연금보험' - ) - """)); - assertEquals(6, count(jdbc, """ + """, VIRTUAL_TEST_PROVIDER); + // 소득 정산처 15종 전체가 등장하려면 사용자 수가 충분해야 한다(기본 50명 기준). + assertTrue(distinctIncomeCounterparties > 0 && distinctIncomeCounterparties <= 15); + int distinctInsuranceProducts = count(jdbc, """ SELECT COUNT(DISTINCT REPLACE(transaction_row.desc3, '홈)', '')) FROM ACCOUNT_TRANSACTION transaction_row JOIN ACCOUNT account_row ON account_row.account_id = transaction_row.account_id - WHERE account_row.user_id BETWEEN ? AND ? + JOIN USERS seeded_user ON seeded_user.user_id = account_row.user_id + WHERE seeded_user.provider = ? AND REPLACE(transaction_row.desc3, '홈)', '') IN ( '삼성생명 실손보험', '현대해상 건강보험', 'DB손해보험 운전자보험', 'KB손해보험 암보험', '교보생명 종신보험', '한화생명 연금보험' ) - """)); + """, VIRTUAL_TEST_PROVIDER); + assertTrue(distinctInsuranceProducts > 0 && distinctInsuranceProducts <= 6); System.out.println("VIRTUAL_FINANCIAL_USERS=" + first.users()); System.out.println("VIRTUAL_FINANCIAL_ACCOUNTS=" + first.accounts()); @@ -134,8 +160,8 @@ AND REPLACE(transaction_row.desc3, '홈)', '') IN ( } } - private static int count(JdbcTemplate jdbc, String sql) { - Integer value = jdbc.queryForObject(sql, Integer.class, FIRST_USER_ID, LAST_USER_ID); + private static int count(JdbcTemplate jdbc, String sql, Object... arguments) { + Integer value = jdbc.queryForObject(sql, Integer.class, arguments); return value == null ? 0 : value; } @@ -187,9 +213,42 @@ VirtualFinancialTransactionGenerator virtualFinancialTransactionGenerator() { @Bean Clock clock() { - // 월말로 고정해 "현재 월"이 항상 완결된 3개월 창이 되도록 하고, 기존 고정 건수 검증값을 그대로 유지한다. - ZoneId zone = ZoneId.of("Asia/Seoul"); - return Clock.fixed(LocalDate.of(2026, 6, 30).atStartOfDay(zone).toInstant(), zone); + // generate()/generateForUsers()는 이 Clock 대신 VirtualDatasetContext.referenceDate()를 쓴다. + return Clock.systemDefaultZone(); + } + + /** + * user-service(이슈 #167)가 USERS에 시딩한 provider=NTROPY_TEST 가상회원을 직접 조회한다. + * account-service 모듈은 user-service에 컴파일 의존성이 없어 VirtualUserProviderId를 재사용할 + * 수 없으므로, 같은 provider_id 형식(virtual-user-{순번6자리})만 이 테스트 안에서 다시 파싱한다. + * 실행 컨텍스트(dataset-version/reference-date/random-seed)는 user-service의 기본값과 같은 + * 고정값을 사용한다. 수동 테스트 실행일이 바뀌어도 생성 결과는 달라지지 않는다. + */ + @Bean + VirtualUserQueryClient virtualUserQueryClient(DataSource dataSource) { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + return () -> { + List users = jdbc.query( + "SELECT user_id, provider_id FROM USERS WHERE provider = ? ORDER BY user_id", + (resultSet, rowNumber) -> new VirtualUserIdentity( + resultSet.getLong("user_id"), + parseOrdinal(resultSet.getString("provider_id")) + ), + VIRTUAL_TEST_PROVIDER + ); + List sorted = users.stream() + .sorted(Comparator.comparingInt(VirtualUserIdentity::ordinal)) + .toList(); + return new VirtualUserDataset(MANUAL_DATASET_CONTEXT, sorted); + }; + } + + private static int parseOrdinal(String providerId) { + String prefix = "virtual-user-"; + if (providerId == null || !providerId.startsWith(prefix)) { + throw new IllegalStateException("가상회원 provider_id 형식이 올바르지 않습니다: " + providerId); + } + return Integer.parseInt(providerId.substring(prefix.length())); } @Bean @@ -198,10 +257,11 @@ VirtualFinancialDataService virtualFinancialDataService( AccountMapper accountMapper, AccountTransactionMapper transactionMapper, VirtualFinancialTransactionGenerator generator, - Clock clock + Clock clock, + VirtualUserQueryClient virtualUserQueryClient ) { return new VirtualFinancialDataService( - connectionService, accountMapper, transactionMapper, generator, clock + connectionService, accountMapper, transactionMapper, generator, clock, virtualUserQueryClient ); } } From e9c2570f14445fe6ca6189c077dbc7d5b3bd02fa Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 10:45:49 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20account-service=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=20=EA=B8=88=EC=9C=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=85=8B=20=EA=B8=B0=EB=8F=99=20=EC=8B=9C=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VirtualFinancialDataSeedInitializer.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializer.java diff --git a/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializer.java b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializer.java new file mode 100644 index 00000000..87b18e6b --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializer.java @@ -0,0 +1,43 @@ +package com.ntropy.account.service; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * local/test 환경에서 가상 금융 데이터셋 기능을 활성화하면 애플리케이션 기동 중 FIN-005 + * 가상 계좌·거래를 멱등 생성한다({@link VirtualFinancialDataService#generate()}). + * user-service의 {@code VirtualUserSeedInitializer}가 먼저 USERS에 가상회원을 시딩해야 + * {@link VirtualFinancialDataService#generate()}가 조회할 대상이 생긴다. 모든 일반 singleton의 + * 초기화 콜백이 끝난 뒤 호출되는 {@link SmartInitializingSingleton}을 사용해 회원 시딩 이후에 + * 실행하되, account-service 단독 컨텍스트에서는 user-service 빈을 강제하지 않는다. + */ +@Slf4j +@Component +public class VirtualFinancialDataSeedInitializer implements SmartInitializingSingleton { + + private final VirtualFinancialDataService virtualFinancialDataService; + private final boolean virtualTestEnabled; + + public VirtualFinancialDataSeedInitializer( + VirtualFinancialDataService virtualFinancialDataService, + @Value("${virtual-test.enabled:false}") boolean virtualTestEnabled + ) { + this.virtualFinancialDataService = virtualFinancialDataService; + this.virtualTestEnabled = virtualTestEnabled; + } + + @Override + public void afterSingletonsInstantiated() { + if (!virtualTestEnabled) { + return; + } + VirtualFinancialDataService.GenerationSummary summary = virtualFinancialDataService.generate(); + log.info( + "가상 금융 데이터셋 시딩 완료: users={}, accounts={}, transactions={}", + summary.users(), summary.accounts(), summary.transactions() + ); + } +} From bd4ca4c097fcd36045f8c61a4be26a68d602d219 Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 10:45:52 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20VirtualFinancialDataSeedInitializer?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=C2=B7=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/account-service/build.gradle | 2 + ...ancialDataSeedInitializerOrderingTest.java | 109 ++++++++++++++++++ ...rtualFinancialDataSeedInitializerTest.java | 44 +++++++ 3 files changed, 155 insertions(+) create mode 100644 services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerOrderingTest.java create mode 100644 services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerTest.java diff --git a/services/account-service/build.gradle b/services/account-service/build.gradle index 5e3cec52..e54bb0d0 100644 --- a/services/account-service/build.gradle +++ b/services/account-service/build.gradle @@ -17,4 +17,6 @@ dependencies { testImplementation "org.springframework:spring-test:${springVersion}" testImplementation "org.springframework:spring-tx:${springVersion}" testImplementation 'org.hamcrest:hamcrest:2.2' + testImplementation 'org.mockito:mockito-core:4.5.1' + testImplementation 'org.mockito:mockito-junit-jupiter:4.5.1' } diff --git a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerOrderingTest.java b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerOrderingTest.java new file mode 100644 index 00000000..4edfb869 --- /dev/null +++ b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerOrderingTest.java @@ -0,0 +1,109 @@ +package com.ntropy.account.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.core.env.MapPropertySource; + +import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary; + +/** + * 일반 singleton의 {@link InitializingBean} 콜백이 모두 끝난 뒤 + * {@link VirtualFinancialDataSeedInitializer}가 실행되는지 검증한다. account-service는 user-service에 + * 컴파일 의존성이 없으므로 실제 회원 초기화 빈 대신 같은 생명주기의 가짜 빈으로 순서만 검증한다. + */ +class VirtualFinancialDataSeedInitializerOrderingTest { + + @Test + void runsAfterVirtualUserSeedInitializerCallback() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getEnvironment().getPropertySources() + .addFirst(new MapPropertySource("test", Map.of("virtual-test.enabled", "true"))); + context.register(TestConfig.class, VirtualFinancialDataSeedInitializer.class); + context.refresh(); + + try { + @SuppressWarnings("unchecked") + List order = context.getBean("executionOrder", List.class); + assertEquals(List.of("virtualUserSeedInitializer", "financialDataSeedInitializer"), order); + } finally { + context.close(); + } + } + + @Test + void disabledAccountContextStartsWithoutVirtualUserSeedInitializer() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getEnvironment().getPropertySources() + .addFirst(new MapPropertySource("test", Map.of("virtual-test.enabled", "false"))); + context.register(DisabledTestConfig.class, VirtualFinancialDataSeedInitializer.class); + + try { + assertDoesNotThrow(context::refresh); + verify(context.getBean(VirtualFinancialDataService.class), never()).generate(); + } finally { + context.close(); + } + } + + @Configuration + static class TestConfig { + + @Bean + static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + @Bean + List executionOrder() { + return new ArrayList<>(); + } + + @Bean(name = "virtualUserSeedInitializer") + InitializingBean virtualUserSeedInitializer(List executionOrder) { + return () -> executionOrder.add("virtualUserSeedInitializer"); + } + + @Bean + VirtualFinancialDataService virtualFinancialDataService(List executionOrder) { + VirtualFinancialDataService service = mock(VirtualFinancialDataService.class); + GenerationSummary summary = new GenerationSummary( + 1, 2, 1, 300, LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30) + ); + doAnswer(invocation -> { + executionOrder.add("financialDataSeedInitializer"); + return summary; + }).when(service).generate(); + return service; + } + } + + @Configuration + static class DisabledTestConfig { + + @Bean + static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + @Bean + VirtualFinancialDataService virtualFinancialDataService() { + return mock(VirtualFinancialDataService.class); + } + } +} diff --git a/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerTest.java b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerTest.java new file mode 100644 index 00000000..6d6ef8d7 --- /dev/null +++ b/services/account-service/src/test/java/com/ntropy/account/service/VirtualFinancialDataSeedInitializerTest.java @@ -0,0 +1,44 @@ +package com.ntropy.account.service; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary; + +@ExtendWith(MockitoExtension.class) +class VirtualFinancialDataSeedInitializerTest { + + @Mock + private VirtualFinancialDataService virtualFinancialDataService; + + @Test + void generatesOnStartupWhenEnabled() { + when(virtualFinancialDataService.generate()).thenReturn(new GenerationSummary( + 50, 100, 15, 15_000, LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30) + )); + VirtualFinancialDataSeedInitializer initializer = + new VirtualFinancialDataSeedInitializer(virtualFinancialDataService, true); + + initializer.afterSingletonsInstantiated(); + + verify(virtualFinancialDataService).generate(); + } + + @Test + void doesNothingOnStartupWhenDisabled() { + VirtualFinancialDataSeedInitializer initializer = + new VirtualFinancialDataSeedInitializer(virtualFinancialDataService, false); + + initializer.afterSingletonsInstantiated(); + + verify(virtualFinancialDataService, never()).generate(); + } +}