diff --git a/api/src/test/java/com/ntropy/integration/VirtualSettlementDepositEndToEndManualVerificationTest.java b/api/src/test/java/com/ntropy/integration/VirtualSettlementDepositEndToEndManualVerificationTest.java new file mode 100644 index 00000000..0caf0b73 --- /dev/null +++ b/api/src/test/java/com/ntropy/integration/VirtualSettlementDepositEndToEndManualVerificationTest.java @@ -0,0 +1,356 @@ +package com.ntropy.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import com.ntropy.account.client.LocalIncomingTransactionQueryClient; +import com.ntropy.account.client.LocalVirtualSettlementDepositCommandClient; +import com.ntropy.account.mapper.AccountMapper; +import com.ntropy.account.mapper.IncomingTransactionQueryMapper; +import com.ntropy.account.mapper.VirtualSettlementDepositMapper; +import com.ntropy.common.client.ActiveUserQueryClient; +import com.ntropy.common.client.IncomingTransactionQueryClient; +import com.ntropy.common.client.NotificationCommandClient; +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient.BatchResult; +import com.ntropy.common.client.VirtualSettlementDepositCommandClient; +import com.ntropy.common.dto.notification.NotificationCreateCommand; +import com.ntropy.common.dto.notification.NotificationSummary; +import com.ntropy.work.client.LocalVirtualSettlementDepositBatchCommandClient; +import com.ntropy.work.config.SettlementBatchUserScopeProperties; +import com.ntropy.work.mapper.JobMapper; +import com.ntropy.work.mapper.JobPlatformMappingMapper; +import com.ntropy.work.mapper.PlatformMapper; +import com.ntropy.work.mapper.SettlementMapper; +import com.ntropy.work.mapper.WorkLogMapper; +import com.ntropy.work.mapper.WorkLogPlatformIncomeMapper; +import com.ntropy.work.service.HolidayService; +import com.ntropy.work.service.SettlementService; +import com.ntropy.work.service.VirtualSettlementDepositService; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** + * 이슈 #216(가상 정산 입금 생성)이 실제 MySQL에서 근무일지 확정 -> 가상계좌 입금 -> 정산 + * 완료까지 전체 흐름대로 동작하는지 검증하는 수동 테스트입니다. + * + *

과거 날짜로 확정된 근무일지 1건을 직접 시딩한 뒤, 이슈 #222에서 추가한 + * {@link LocalVirtualSettlementDepositBatchCommandClient}(스웨거 테스트 엔드포인트가 호출하는 + * 것과 동일한 클래스)를 그대로 호출해 가상 입금 생성과 정산 매칭을 한 번에 실행합니다. + * account-service의 ACCOUNT_TRANSACTION/ACCOUNT.balance와 work-service의 + * WORK_LOG_PLATFORM_INCOME/WORK_LOG/SETTLEMENT를 모두 실제 DB에서 재조회해 검증하므로, + * work-service와 account-service가 common 계약을 통해 실제로 맞물려 동작하는지까지 확인합니다. + * RUN_VIRTUAL_SETTLEMENT_DEPOSIT_TEST=true일 때만 실행합니다. work-service/account-service를 + * 모두 참조해야 해서 두 모듈을 전부 의존성으로 갖는 api 모듈에 둡니다 + * (DiagnosisFinalizationManualVerificationTest와 동일한 이유).

+ */ +class VirtualSettlementDepositEndToEndManualVerificationTest { + + private static final Long USER_ID = 9_999_999_222L; + private static final Long PLATFORM_ID = 999_222L; + private static final String DEPOSIT_NAME = "E2E테스트정산"; + + @Test + void confirmedPastWorkLog_createsVirtualDepositAndCompletesSettlement() throws Exception { + assumeTrue( + "true".equalsIgnoreCase(System.getenv("RUN_VIRTUAL_SETTLEMENT_DEPOSIT_TEST")), + "실제 MySQL이 필요한 이슈 #216/#222 종단 검증용 테스트" + ); + + LocalDate workDate = LocalDate.now().minusDays(10); + LocalDate expectedDepositDate = workDate.plusDays(1); // DAILY + CALENDAR_DAY offset=1 + + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(TestConfig.class)) { + DataSource dataSource = context.getBean(DataSource.class); + long logId = seedConfirmedPastWorkLog(dataSource, workDate); + + LocalVirtualSettlementDepositBatchCommandClient batchClient = + context.getBean(LocalVirtualSettlementDepositBatchCommandClient.class); + + BatchResult result = batchClient.runForDate(USER_ID, LocalDate.now()); + + assertEquals(1, result.createdDepositCount(), "가상 입금이 생성되지 않았습니다"); + assertEquals(1, result.matchedSettlementCount(), "생성된 입금이 정산 매칭되지 않았습니다"); + + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + + try (ResultSet rs = statement.executeQuery( + "SELECT at.in_amount, at.tran_date, at.transaction_category, a.balance " + + "FROM ACCOUNT_TRANSACTION at JOIN ACCOUNT a ON a.account_id = at.account_id " + + "WHERE a.user_id = " + USER_ID)) { + assertTrue(rs.next(), "ACCOUNT_TRANSACTION에 가상 입금 거래가 없습니다"); + assertEquals(0, BigDecimal.valueOf(45_000).compareTo(rs.getBigDecimal("in_amount")), + "입금액이 예상 소득과 다릅니다"); + assertEquals(expectedDepositDate.toString(), rs.getDate("tran_date").toLocalDate().toString()); + assertEquals("ORDINARY", rs.getString("transaction_category")); + assertEquals(0, BigDecimal.valueOf(145_000).compareTo(rs.getBigDecimal("balance")), + "가상계좌 잔액이 입금액만큼 반영되지 않았습니다"); + } + + try (ResultSet rs = statement.executeQuery( + "SELECT settlement_status FROM WORK_LOG_PLATFORM_INCOME WHERE log_id = " + logId)) { + assertTrue(rs.next()); + assertEquals("COMPLETED", rs.getString("settlement_status"), + "플랫폼 소득이 정산 완료로 갱신되지 않았습니다"); + } + + try (ResultSet rs = statement.executeQuery( + "SELECT settlement_status FROM WORK_LOG WHERE log_id = " + logId)) { + assertTrue(rs.next()); + assertEquals("COMPLETED", rs.getString("settlement_status"), + "근무일지 정산 상태가 정산 완료로 갱신되지 않았습니다"); + } + + try (ResultSet rs = statement.executeQuery( + "SELECT expected_amount, actual_amount, deposit_date FROM SETTLEMENT " + + "WHERE user_id = " + USER_ID)) { + assertTrue(rs.next(), "SETTLEMENT 행이 생성되지 않았습니다"); + assertEquals(45_000L, rs.getLong("expected_amount")); + assertEquals(45_000L, rs.getLong("actual_amount")); + assertEquals(expectedDepositDate.toString(), rs.getDate("deposit_date").toLocalDate().toString()); + } + } + + System.out.println("VIRTUAL_SETTLEMENT_DEPOSIT_TEST_WORK_DATE=" + workDate); + System.out.println("VIRTUAL_SETTLEMENT_DEPOSIT_TEST_DEPOSIT_DATE=" + expectedDepositDate); + System.out.println("VIRTUAL_SETTLEMENT_DEPOSIT_TEST_RESULT=" + result); + } + } + + /** 확정된(CONFIRMED) 근무일지 1건 + 플랫폼 소득 + 매칭용 NTROPY 가상 수시입출금 계좌를 시딩한다. */ + private long seedConfirmedPastWorkLog(DataSource dataSource, LocalDate workDate) throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + + // 이전 실행 잔여 데이터 정리(이 테스트 전용 user_id/platform_id 범위만). + statement.execute("DELETE FROM SETTLEMENT WHERE user_id = " + USER_ID); + statement.execute( + "DELETE FROM WORK_LOG_PLATFORM_INCOME WHERE platform_id = " + PLATFORM_ID); + statement.execute("DELETE FROM WORK_LOG WHERE user_id = " + USER_ID); + statement.execute("DELETE FROM JOBPLATFORMMAPPING WHERE platform_id = " + PLATFORM_ID); + statement.execute("DELETE FROM JOB WHERE user_id = " + USER_ID); + statement.execute("DELETE FROM PLATFORM WHERE platform_id = " + PLATFORM_ID); + statement.execute( + "DELETE FROM ACCOUNT_TRANSACTION WHERE account_id IN (" + + "SELECT account_id FROM ACCOUNT WHERE user_id = " + USER_ID + ")" + ); + statement.execute("DELETE FROM ACCOUNT WHERE user_id = " + USER_ID); + statement.execute("DELETE FROM CODEF_CONNECTION WHERE user_id = " + USER_ID); + + // 플랫폼: DAILY + CALENDAR_DAY, offset=1 -> 근무일 다음 날이 예상 정산일. + statement.execute( + "INSERT INTO PLATFORM (platform_id, category_id, platform_name, deposit_name, " + + "settlement_cycle, settlement_trigger_type, settlement_offset_day, " + + "settlement_offset_unit) VALUES (" + + PLATFORM_ID + ", 1, 'E2E테스트플랫폼', '" + DEPOSIT_NAME + "', " + + "'DAILY', 'AUTO', 1, 'CALENDAR_DAY')" + ); + + statement.execute( + "INSERT INTO JOB (user_id, category_id, job_name, settlement_type, hourly_wage, " + + "is_regular, base_fatigue, created_at, updated_at, is_active) VALUES (" + + USER_ID + ", 1, 'E2E테스트잡', 'HOURLY', 15000, 1, 1, NOW(), NOW(), 1)" + ); + long jobId = lastInsertId(statement); + + statement.execute( + "INSERT INTO JOBPLATFORMMAPPING (job_id, platform_id) VALUES (" + + jobId + ", " + PLATFORM_ID + ")" + ); + + // 근무일지 확정: 실제 화면에서 "확정" 버튼을 눌렀을 때와 동일한 상태(CONFIRMED)로 시딩한다. + statement.execute( + "INSERT INTO WORK_LOG (user_id, job_id, work_date, fatigue, status, settlement_status) " + + "VALUES (" + USER_ID + ", " + jobId + ", '" + workDate + "', 1, " + + "'CONFIRMED', 'PENDING')" + ); + long logId = lastInsertId(statement); + + statement.execute( + "INSERT INTO WORK_LOG_PLATFORM_INCOME (log_id, platform_id, expected_amount, " + + "settlement_status) VALUES (" + + logId + ", " + PLATFORM_ID + ", 45000, 'PENDING')" + ); + + // NTROPY 가상 수시입출금 계좌: LocalVirtualSettlementDepositCommandClient가 입금 대상으로 + // 조회하는 조건(provider=NTROPY, account_group=DEPOSIT_TRUST, deposit_type_code=11)과 동일하게 시딩. + statement.execute( + "INSERT INTO CODEF_CONNECTION (user_id, provider, connected_id) VALUES (" + + USER_ID + ", 'NTROPY', 'NTROPY-e2e-222')" + ); + long connectionId = lastInsertId(statement); + + statement.execute( + "INSERT INTO ACCOUNT (codef_connection_id, user_id, organization_code, account_group, " + + "deposit_type_code, account_no_masked, account_no_hash, balance, currency_code, " + + "overdraft_yn, status) VALUES (" + + connectionId + ", " + USER_ID + ", '0088', 'DEPOSIT_TRUST', '11', " + + "'****9222', SHA2('e2e-222-account', 256), 100000, 'KRW', 0, 'ACTIVE')" + ); + + return logId; + } + } + + private long lastInsertId(Statement statement) throws Exception { + try (ResultSet rs = statement.executeQuery("SELECT LAST_INSERT_ID()")) { + rs.next(); + return rs.getLong(1); + } + } + + private static class NoopNotificationCommandClient implements NotificationCommandClient { + @Override + public NotificationSummary create(NotificationCreateCommand command) { + return null; + } + + @Override + public void markAsRead(Long userId, Long notificationId) { + } + + @Override + public void delete(Long userId, Long notificationId) { + } + } + + private static class NoHolidayService extends HolidayService { + private NoHolidayService() { + super(null, null); + } + + @Override + public Set getHolidays(LocalDate startDate, LocalDate endDate) { + return Set.of(); + } + } + + @Configuration + @EnableTransactionManagement + @MapperScan(basePackageClasses = {PlatformMapper.class, AccountMapper.class}) + static class TestConfig { + + @Bean + DataSource dataSource() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(System.getenv().getOrDefault( + "VIRTUAL_SETTLEMENT_TEST_DB_URL", + "jdbc:mysql://localhost:3307/db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8" + )); + config.setUsername(System.getenv().getOrDefault("VIRTUAL_SETTLEMENT_TEST_DB_USERNAME", "root")); + config.setPassword(System.getenv().getOrDefault("VIRTUAL_SETTLEMENT_TEST_DB_PASSWORD", "root")); + config.setDriverClassName("com.mysql.cj.jdbc.Driver"); + return new HikariDataSource(config); + } + + @Bean + SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) throws Exception { + SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setMapperLocations( + new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/**/*.xml") + ); + return factoryBean; + } + + @Bean + DataSourceTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + + @Bean + ActiveUserQueryClient activeUserQueryClient() { + return scope -> List.of(USER_ID); + } + + @Bean + SettlementBatchUserScopeProperties settlementBatchUserScopeProperties() { + return new SettlementBatchUserScopeProperties("ALL"); + } + + @Bean + HolidayService holidayService() { + return new NoHolidayService(); + } + + @Bean + NotificationCommandClient notificationCommandClient() { + return new NoopNotificationCommandClient(); + } + + @Bean + VirtualSettlementDepositCommandClient virtualSettlementDepositCommandClient( + AccountMapper accountMapper, VirtualSettlementDepositMapper virtualSettlementDepositMapper) { + return new LocalVirtualSettlementDepositCommandClient(accountMapper, virtualSettlementDepositMapper); + } + + @Bean + IncomingTransactionQueryClient incomingTransactionQueryClient( + IncomingTransactionQueryMapper incomingTransactionQueryMapper) { + return new LocalIncomingTransactionQueryClient(incomingTransactionQueryMapper); + } + + @Bean + VirtualSettlementDepositService virtualSettlementDepositService( + ActiveUserQueryClient activeUserQueryClient, + SettlementBatchUserScopeProperties userScopeProperties, + WorkLogPlatformIncomeMapper workLogPlatformIncomeMapper, + PlatformMapper platformMapper, + HolidayService holidayService, + VirtualSettlementDepositCommandClient virtualSettlementDepositCommandClient) { + return new VirtualSettlementDepositService( + activeUserQueryClient, userScopeProperties, workLogPlatformIncomeMapper, + platformMapper, holidayService, virtualSettlementDepositCommandClient); + } + + @Bean + SettlementService settlementService( + IncomingTransactionQueryClient incomingTransactionQueryClient, + ActiveUserQueryClient activeUserQueryClient, + SettlementBatchUserScopeProperties userScopeProperties, + PlatformMapper platformMapper, + JobMapper jobMapper, + JobPlatformMappingMapper jobPlatformMappingMapper, + WorkLogMapper workLogMapper, + WorkLogPlatformIncomeMapper workLogPlatformIncomeMapper, + SettlementMapper settlementMapper, + HolidayService holidayService, + NotificationCommandClient notificationCommandClient) { + return new SettlementService( + incomingTransactionQueryClient, activeUserQueryClient, userScopeProperties, + platformMapper, jobMapper, jobPlatformMappingMapper, workLogMapper, + workLogPlatformIncomeMapper, settlementMapper, holidayService, notificationCommandClient); + } + + @Bean + LocalVirtualSettlementDepositBatchCommandClient localVirtualSettlementDepositBatchCommandClient( + VirtualSettlementDepositService virtualSettlementDepositService, + SettlementService settlementService) { + return new LocalVirtualSettlementDepositBatchCommandClient( + virtualSettlementDepositService, settlementService); + } + } +} diff --git a/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositBatchCommandClient.java b/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositBatchCommandClient.java new file mode 100644 index 00000000..aaac3020 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositBatchCommandClient.java @@ -0,0 +1,14 @@ +package com.ntropy.common.client; + +import java.time.LocalDate; + +/** 가상 정산 입금 생성과 후속 정산 매칭을 사용자·날짜 단위로 수동 실행하기 위한 테스트 계약. */ +public interface VirtualSettlementDepositBatchCommandClient { + + /** 특정 사용자의 도래한 가상 입금을 생성하고, 생성된 입금일의 정산 매칭까지 즉시 실행한다. */ + BatchResult runForDate(Long userId, LocalDate processDate); + + /** 수동 배치 한 번의 실행 결과. 실제 금액이나 계좌 정보는 노출하지 않고 건수만 반환한다. */ + record BatchResult(int createdDepositCount, int matchedSettlementCount) { + } +} diff --git a/services/bff-service/src/main/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestController.java b/services/bff-service/src/main/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestController.java new file mode 100644 index 00000000..8bf092ee --- /dev/null +++ b/services/bff-service/src/main/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestController.java @@ -0,0 +1,43 @@ +package com.ntropy.bff.controller.internal; + +import java.time.LocalDate; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ntropy.bff.dto.common.ApiResponse; +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient; +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient.BatchResult; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.RequiredArgsConstructor; + +/** + * [테스트용] 특정 사용자·날짜의 가상 정산 입금 생성과 후속 정산 매칭을 즉시 실행한다. + * 검증이 끝나면 기존 SettlementTestController, CodefSyncTestController와 함께 삭제할 대상이다. + */ +@Api(tags = "[테스트용] 가상 정산 입금 배치 수동 실행") +@RestController +@RequestMapping("/internal/test/virtual-settlement") +@RequiredArgsConstructor +public class VirtualSettlementDepositTestController { + + private final VirtualSettlementDepositBatchCommandClient batchCommandClient; + + @ApiOperation("특정 사용자의 가상 정산 입금을 생성하고 정산 매칭까지 지금 바로 실행한다") + @GetMapping("/run") + public ApiResponse run( + @RequestParam Long userId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + BatchResult result = batchCommandClient.runForDate(userId, date); + String message = String.format( + "가상 정산 입금 배치를 실행했습니다. 생성 입금 %d건, 매칭 SETTLEMENT %d건", + result.createdDepositCount(), result.matchedSettlementCount()); + return ApiResponse.success(200, message, result); + } +} diff --git a/services/bff-service/src/test/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestControllerTest.java b/services/bff-service/src/test/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestControllerTest.java new file mode 100644 index 00000000..a304c944 --- /dev/null +++ b/services/bff-service/src/test/java/com/ntropy/bff/controller/internal/VirtualSettlementDepositTestControllerTest.java @@ -0,0 +1,65 @@ +package com.ntropy.bff.controller.internal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.time.LocalDate; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.ntropy.bff.dto.common.ApiResponse; +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient; +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient.BatchResult; + +class VirtualSettlementDepositTestControllerTest { + + @Test + void exposesInternalTestEndpoint() throws Exception { + RequestMapping requestMapping = VirtualSettlementDepositTestController.class + .getAnnotation(RequestMapping.class); + Method run = VirtualSettlementDepositTestController.class + .getDeclaredMethod("run", Long.class, LocalDate.class); + + assertArrayEquals(new String[] {"/internal/test/virtual-settlement"}, requestMapping.value()); + assertArrayEquals(new String[] {"/run"}, run.getAnnotation(GetMapping.class).value()); + } + + @Test + void returnsCreatedDepositAndMatchedSettlementCounts() { + RecordingBatchClient client = new RecordingBatchClient(new BatchResult(2, 3)); + VirtualSettlementDepositTestController controller = + new VirtualSettlementDepositTestController(client); + LocalDate date = LocalDate.of(2026, 8, 23); + + ApiResponse response = controller.run(42L, date); + + assertEquals(42L, client.userId); + assertEquals(date, client.date); + assertEquals(200, response.getStatus_code()); + assertEquals(2, response.getData().createdDepositCount()); + assertEquals(3, response.getData().matchedSettlementCount()); + assertEquals( + "가상 정산 입금 배치를 실행했습니다. 생성 입금 2건, 매칭 SETTLEMENT 3건", + response.getMessage()); + } + + private static final class RecordingBatchClient implements VirtualSettlementDepositBatchCommandClient { + private final BatchResult result; + private Long userId; + private LocalDate date; + + private RecordingBatchClient(BatchResult result) { + this.result = result; + } + + @Override + public BatchResult runForDate(Long userId, LocalDate processDate) { + this.userId = userId; + date = processDate; + return result; + } + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClient.java b/services/work-service/src/main/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClient.java new file mode 100644 index 00000000..95ea9340 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClient.java @@ -0,0 +1,55 @@ +package com.ntropy.work.client; + +import java.time.LocalDate; + +import org.springframework.stereotype.Component; + +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; +import com.ntropy.work.service.SettlementService; +import com.ntropy.work.service.VirtualSettlementDepositService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** 로컬 모듈 안에서 가상 입금 생성과 정산 매칭을 연속 실행하는 테스트용 어댑터. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LocalVirtualSettlementDepositBatchCommandClient + implements VirtualSettlementDepositBatchCommandClient { + + private final VirtualSettlementDepositService virtualSettlementDepositService; + private final SettlementService settlementService; + + @Override + public BatchResult runForDate(Long userId, LocalDate processDate) { + VirtualSettlementDepositBatchResult depositResult = + virtualSettlementDepositService.processUser(userId, processDate); + + int matchedSettlementCount = 0; + long matchedSettlementAmount = 0L; + for (VirtualSettlementDepositBatchResult.MatchTarget target : depositResult.matchTargets()) { + try { + SettlementService.SettlementBatchOutcome outcome = settlementService.processSettlementDetailed( + target.userId(), target.depositDate()); + matchedSettlementCount = Math.addExact(matchedSettlementCount, outcome.createdCount()); + matchedSettlementAmount = Math.addExact(matchedSettlementAmount, outcome.totalAmount()); + } catch (RuntimeException e) { + // 한 매칭 대상의 실패가 같은 호출의 다른 대상 매칭·응답까지 날려버리지 않게 격리한다. + log.error("[가상 정산 입금 테스트] 정산 매칭 실패. userId={}, depositDate={}", + target.userId(), target.depositDate(), e); + } + } + + if (matchedSettlementCount > 0) { + settlementService.notifySettlementCompleted( + userId, processDate, matchedSettlementCount, matchedSettlementAmount); + } + + log.info("[가상 정산 입금 테스트] 수동 배치 완료. userId={}, processDate={}, " + + "createdDeposits={}, matchedSettlements={}", + userId, processDate, depositResult.createdCount(), matchedSettlementCount); + return new BatchResult(depositResult.createdCount(), matchedSettlementCount); + } +} diff --git a/services/work-service/src/test/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClientTest.java b/services/work-service/src/test/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClientTest.java new file mode 100644 index 00000000..3b97a08d --- /dev/null +++ b/services/work-service/src/test/java/com/ntropy/work/client/LocalVirtualSettlementDepositBatchCommandClientTest.java @@ -0,0 +1,167 @@ +package com.ntropy.work.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.ntropy.common.client.VirtualSettlementDepositBatchCommandClient.BatchResult; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult.MatchTarget; +import com.ntropy.work.service.SettlementService; +import com.ntropy.work.service.VirtualSettlementDepositService; + +class LocalVirtualSettlementDepositBatchCommandClientTest { + + @Test + void createsVirtualDepositsAndImmediatelyMatchesEveryTargetDate() { + Long userId = 42L; + LocalDate processDate = LocalDate.of(2026, 8, 23); + LocalDate firstDepositDate = processDate.minusDays(30); + LocalDate secondDepositDate = processDate.minusDays(1); + StubVirtualDepositService depositService = new StubVirtualDepositService( + new VirtualSettlementDepositBatchResult(2, List.of( + new MatchTarget(userId, firstDepositDate), + new MatchTarget(userId, secondDepositDate) + ))); + RecordingSettlementService settlementService = new RecordingSettlementService( + List.of( + new SettlementService.SettlementBatchOutcome(1, 40_000L), + new SettlementService.SettlementBatchOutcome(2, 70_000L) + )); + LocalVirtualSettlementDepositBatchCommandClient client = + new LocalVirtualSettlementDepositBatchCommandClient(depositService, settlementService); + + BatchResult result = client.runForDate(userId, processDate); + + assertEquals(2, result.createdDepositCount()); + assertEquals(3, result.matchedSettlementCount()); + assertEquals(userId, depositService.processedUserId); + assertEquals(processDate, depositService.processedDate); + assertEquals(List.of(firstDepositDate, secondDepositDate), settlementService.processedDates); + assertEquals(userId, settlementService.notifiedUserId); + assertEquals(processDate, settlementService.notifiedDate); + assertEquals(3, settlementService.notifiedCount); + assertEquals(110_000L, settlementService.notifiedAmount); + } + + @Test + void returnsOnlyCreatedCountWhenThereAreNoMatchTargets() { + Long userId = 42L; + LocalDate processDate = LocalDate.of(2026, 8, 23); + StubVirtualDepositService depositService = new StubVirtualDepositService( + new VirtualSettlementDepositBatchResult(1, List.of())); + RecordingSettlementService settlementService = new RecordingSettlementService(List.of()); + LocalVirtualSettlementDepositBatchCommandClient client = + new LocalVirtualSettlementDepositBatchCommandClient(depositService, settlementService); + + BatchResult result = client.runForDate(userId, processDate); + + assertEquals(1, result.createdDepositCount()); + assertEquals(0, result.matchedSettlementCount()); + assertEquals(List.of(), settlementService.processedDates); + assertNull(settlementService.notifiedUserId); + } + + @Test + void isolatesOneTargetsMatchingFailureFromTheOthers() { + Long userId = 42L; + LocalDate processDate = LocalDate.of(2026, 8, 23); + LocalDate failingDepositDate = processDate.minusDays(30); + LocalDate succeedingDepositDate = processDate.minusDays(1); + StubVirtualDepositService depositService = new StubVirtualDepositService( + new VirtualSettlementDepositBatchResult(2, List.of( + new MatchTarget(userId, failingDepositDate), + new MatchTarget(userId, succeedingDepositDate) + ))); + RecordingSettlementService settlementService = new RecordingSettlementService( + List.of(new SettlementService.SettlementBatchOutcome(1, 40_000L))); + settlementService.failOnDate = failingDepositDate; + LocalVirtualSettlementDepositBatchCommandClient client = + new LocalVirtualSettlementDepositBatchCommandClient(depositService, settlementService); + + BatchResult result = client.runForDate(userId, processDate); + + assertEquals(2, result.createdDepositCount()); + assertEquals(1, result.matchedSettlementCount()); + assertEquals(List.of(failingDepositDate, succeedingDepositDate), settlementService.processedDates); + assertEquals(1, settlementService.notifiedCount); + assertEquals(40_000L, settlementService.notifiedAmount); + } + + @Test + void matchesAvailableTargetEvenWhenDepositWasAlreadyCreated() { + Long userId = 42L; + LocalDate processDate = LocalDate.of(2026, 8, 23); + LocalDate depositDate = processDate.minusDays(7); + StubVirtualDepositService depositService = new StubVirtualDepositService( + new VirtualSettlementDepositBatchResult( + 0, List.of(new MatchTarget(userId, depositDate)))); + RecordingSettlementService settlementService = new RecordingSettlementService( + List.of(new SettlementService.SettlementBatchOutcome(1, 50_000L))); + LocalVirtualSettlementDepositBatchCommandClient client = + new LocalVirtualSettlementDepositBatchCommandClient(depositService, settlementService); + + BatchResult result = client.runForDate(userId, processDate); + + assertEquals(0, result.createdDepositCount()); + assertEquals(1, result.matchedSettlementCount()); + assertEquals(List.of(depositDate), settlementService.processedDates); + assertEquals(1, settlementService.notifiedCount); + } + + private static final class StubVirtualDepositService extends VirtualSettlementDepositService { + private final VirtualSettlementDepositBatchResult result; + private Long processedUserId; + private LocalDate processedDate; + + private StubVirtualDepositService(VirtualSettlementDepositBatchResult result) { + super(null, null, null, null, null, null); + this.result = result; + } + + @Override + public VirtualSettlementDepositBatchResult processUser(Long userId, LocalDate processDate) { + processedUserId = userId; + processedDate = processDate; + return result; + } + } + + private static final class RecordingSettlementService extends SettlementService { + private final List outcomes; + private final List processedDates = new ArrayList<>(); + private int outcomeIndex; + private Long notifiedUserId; + private LocalDate notifiedDate; + private int notifiedCount; + private long notifiedAmount; + private LocalDate failOnDate; + + private RecordingSettlementService(List outcomes) { + super(null, null, null, null, null, null, null, null, null, null, null); + this.outcomes = outcomes; + } + + @Override + public SettlementBatchOutcome processSettlementDetailed(Long userId, LocalDate processDate) { + processedDates.add(processDate); + if (processDate.equals(failOnDate)) { + throw new IllegalStateException("정산 매칭 실패 시뮬레이션"); + } + return outcomes.get(outcomeIndex++); + } + + @Override + public void notifySettlementCompleted(Long userId, LocalDate today, int count, long totalAmount) { + notifiedUserId = userId; + notifiedDate = today; + notifiedCount = count; + notifiedAmount = totalAmount; + } + } +}