diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/transform/MappingEngineTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/transform/MappingEngineTest.java new file mode 100644 index 0000000..2e17c6f --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/transform/MappingEngineTest.java @@ -0,0 +1,382 @@ +package com.ga.batch.settlement.transform; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ga.core.mapper.ledger.MaintainLedgerMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.mapper.receive.ReceiveMapper; +import com.ga.core.vo.ledger.LedgerVO; +import com.ga.core.vo.product.ContractResp; +import com.ga.core.vo.product.ContractSearchParam; +import com.ga.core.vo.product.ContractVO; +import com.ga.core.vo.receive.CompanyFieldMappingVO; +import com.ga.core.vo.receive.ParseErrorLogVO; +import com.ga.core.vo.receive.RawCommissionDataVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MappingEngineTest { + + private ReceiveMapper receiveMapper; + private ContractMapper contractMapper; + private RecruitLedgerMapper recruitMapper; + private MaintainLedgerMapper maintainMapper; + private MappingEngine engine; + + @BeforeEach + void setUp() { + receiveMapper = mock(ReceiveMapper.class); + contractMapper = mock(ContractMapper.class); + recruitMapper = mock(RecruitLedgerMapper.class); + maintainMapper = mock(MaintainLedgerMapper.class); + engine = new MappingEngine(receiveMapper, contractMapper, recruitMapper, maintainMapper, new ObjectMapper()); + } + + /** 매핑 정의 1건 생성 헬퍼 */ + private CompanyFieldMappingVO mapping(String src, String tgt, String dataType, String rule, String defVal, String required) { + CompanyFieldMappingVO m = new CompanyFieldMappingVO(); + m.setSourceField(src); + m.setTargetField(tgt); + m.setDataType(dataType); + m.setTransformRule(rule); + m.setDefaultValue(defVal); + m.setIsRequired(required); + return m; + } + + /** raw 1건 + 정상 mapping 정의 + 계약 매칭까지 갖춘 행복 경로 셋업 */ + private RawCommissionDataVO basicRecruitRaw(String json) { + RawCommissionDataVO raw = new RawCommissionDataVO(); + raw.setRawId(1000L); + raw.setCompanyCode("SAMSUNG"); + raw.setSettleMonth("202405"); + raw.setDataType("RECRUIT"); + raw.setRawContent(json); + return raw; + } + + private void setupContractMatch(String policyNo, Long contractId, Long agentId) { + ContractResp resp = new ContractResp(); + resp.setContractId(contractId); + when(contractMapper.selectList(any(ContractSearchParam.class))) + .thenReturn(List.of(resp)); + + ContractVO vo = new ContractVO(); + vo.setContractId(contractId); + vo.setAgentId(agentId); + vo.setPolicyNo(policyNo); + vo.setPremium(new BigDecimal("500000")); + vo.setPayCycle("MONTHLY"); + when(contractMapper.selectById(contractId)).thenReturn(vo); + } + + @Test + @DisplayName("RECRUIT 행복경로: TRIM/DATE/CODE_MAP 모두 적용 + recruitMapper.insert 호출") + void recruit_happyPath() { + String json = """ + {"policyNo":" POL-001 ", "premium":"1000000", "amt":"75000", + "agentAmt":"45000", "compRate":"7.5", "payRate":"60", + "type":"WL_RAW"} + """; + RawCommissionDataVO raw = basicRecruitRaw(json); + + when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of( + mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y"), + mapping("premium", "premium", "DECIMAL", null, null, "N"), + mapping("amt", "company_amount", "DECIMAL", null, null, "N"), + mapping("agentAmt", "agent_amount", "DECIMAL", null, null, "N"), + mapping("compRate", "company_rate", "DECIMAL", null, null, "N"), + mapping("payRate", "payout_rate", "DECIMAL", null, null, "N"), + mapping("type", "insurance_type", "STRING", "CODE_MAP", "INSURANCE_TYPE", "N") + )); + when(receiveMapper.findTargetCode("SAMSUNG", "INSURANCE_TYPE", "WL_RAW")).thenReturn("WHOLE_LIFE"); + setupContractMatch("POL-001", 100L, 10L); + + Long ledgerId = engine.transform(raw); + + // recruit insert 호출 / maintain 미호출 + ArgumentCaptor cap = ArgumentCaptor.forClass(LedgerVO.class); + verify(recruitMapper).insert(cap.capture()); + verify(maintainMapper, never()).insert(any()); + + LedgerVO led = cap.getValue(); + assertThat(led.getContractId()).isEqualTo(100L); + assertThat(led.getAgentId()).isEqualTo(10L); + assertThat(led.getPolicyNo()).isEqualTo("POL-001"); // TRIM + assertThat(led.getCompanyCode()).isEqualTo("SAMSUNG"); + assertThat(led.getInsuranceType()).isEqualTo("WHOLE_LIFE"); // CODE_MAP 변환 + assertThat(led.getPremium()).isEqualByComparingTo("1000000"); + assertThat(led.getCompanyAmount()).isEqualByComparingTo("75000"); + assertThat(led.getAgentAmount()).isEqualByComparingTo("45000"); + assertThat(led.getCompanyRate()).isEqualByComparingTo("7.5"); + assertThat(led.getPayoutRate()).isEqualByComparingTo("60"); + // 세금은 agent_amount × 3.3% 자동 계산: 45000 × 3.3% = 1485 + assertThat(led.getTaxAmount()).isEqualByComparingTo("1485.00"); + assertThat(led.getSettleMonth()).isEqualTo("202405"); + assertThat(led.getStatus()).isEqualTo("CALCULATED"); + assertThat(led.getReconcileStatus()).isEqualTo("PENDING"); + // ledger 의 ID 는 insert 후 채워지지만, 반환값은 mapped LedgerId 그대로 + assertThat(ledgerId).isEqualTo(led.getLedgerId()); + } + + @Test + @DisplayName("MAINTAIN 데이터타입은 maintainMapper.insert 로 라우팅") + void maintain_routedToMaintainMapper() { + String json = """ + {"policyNo":"POL-002", "premium":"500000"} + """; + RawCommissionDataVO raw = new RawCommissionDataVO(); + raw.setRawId(2000L); + raw.setCompanyCode("HANWHA"); + raw.setSettleMonth("202405"); + raw.setDataType("MAINTAIN"); + raw.setRawContent(json); + + when(receiveMapper.selectFieldMappings("HANWHA", "maintain_ledger")).thenReturn(List.of( + mapping("policyNo", "policy_no", "STRING", null, null, "Y"), + mapping("premium", "premium", "DECIMAL", null, null, "N") + )); + setupContractMatch("POL-002", 200L, 20L); + + engine.transform(raw); + + verify(maintainMapper).insert(any(LedgerVO.class)); + verify(recruitMapper, never()).insert(any()); + } + + @Test + @DisplayName("UPPER/LOWER/DIVIDE/MULTIPLY 변환 규칙 검증") + void multipleTransformRules() { + String json = """ + {"upper":"hello", "lower":"WORLD", + "div":"100000", "mul":"5"} + """; + RawCommissionDataVO raw = basicRecruitRaw(json); + // policy_no 도 필수이므로 추가 + String j2 = json.replace("{", "{\"pol\":\"POL-X\","); + raw.setRawContent(j2); + + when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of( + mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"), + // attr1 등 표준 외 필드는 mapped 에 들어가지만 LedgerVO 에 매핑되진 않음 — 변환만 검증 + mapping("upper", "product_code", "STRING", "UPPER", null, "N"), + mapping("lower", "pay_method", "STRING", "LOWER", null, "N"), + mapping("div", "agent_amount", "DECIMAL", "DIVIDE", "100", "N"), + mapping("mul", "company_amount", "DECIMAL", "MULTIPLY", "1000","N") + )); + setupContractMatch("POL-X", 300L, 30L); + + engine.transform(raw); + + ArgumentCaptor cap = ArgumentCaptor.forClass(LedgerVO.class); + verify(recruitMapper).insert(cap.capture()); + LedgerVO led = cap.getValue(); + + assertThat(led.getProductCode()).isEqualTo("HELLO"); // UPPER + assertThat(led.getPayMethod()).isEqualTo("world"); // LOWER + // DIVIDE: 100000 / 100 = 1000.00 + assertThat(led.getAgentAmount()).isEqualByComparingTo("1000.00"); + // MULTIPLY: 5 × 1000 = 5000 + assertThat(led.getCompanyAmount()).isEqualByComparingTo("5000"); + } + + @Test + @DisplayName("DATE 변환: defaultValue 의 패턴으로 파싱") + void dateTransform() { + // mapped 의 "contract_date" 등은 LedgerVO 에 직접 안 들어가므로, + // 변환만 일어나고 buildLedger 에서 기본값을 쓰는지 확인하기 어려움. + // 대신 변환 자체가 실패해서 에러가 안 나는지로 확인. + String json = """ + {"pol":"POL-D", "dateField":"20240515"} + """; + RawCommissionDataVO raw = basicRecruitRaw(json); + raw.setRawContent(json); + + when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of( + mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"), + mapping("dateField", "contract_date", "DATE", "DATE", "yyyyMMdd", "N") + )); + setupContractMatch("POL-D", 400L, 40L); + + Long id = engine.transform(raw); + + // 정상 처리 (예외 없음) + verify(recruitMapper).insert(any(LedgerVO.class)); + verify(receiveMapper, never()).insertError(any()); + } + + @Test + @DisplayName("INVALID_INPUT: company_code 가 null 이면 에러 로그 + null 반환") + void invalidInput_nullCompanyCode() { + RawCommissionDataVO raw = new RawCommissionDataVO(); + raw.setRawId(1L); + raw.setRawContent("{}"); + raw.setDataType("RECRUIT"); + // companyCode 누락 + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("INVALID_INPUT"); + verify(recruitMapper, never()).insert(any()); + } + + @Test + @DisplayName("UNKNOWN_TYPE: 지원하지 않는 data_type") + void unknownDataType() { + RawCommissionDataVO raw = new RawCommissionDataVO(); + raw.setRawId(2L); + raw.setCompanyCode("SAMSUNG"); + raw.setRawContent("{}"); + raw.setDataType("STRANGE"); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("UNKNOWN_TYPE"); + } + + @Test + @DisplayName("NO_MAPPING: 매핑 정의가 비어있음") + void noMapping() { + RawCommissionDataVO raw = basicRecruitRaw("{}"); + when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")) + .thenReturn(new ArrayList<>()); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("NO_MAPPING"); + } + + @Test + @DisplayName("JSON_PARSE: 잘못된 raw_content") + void jsonParseError() { + RawCommissionDataVO raw = basicRecruitRaw("{not-json"); + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("a", "policy_no", "STRING", null, null, "Y") + )); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("JSON_PARSE"); + } + + @Test + @DisplayName("REQUIRED: 필수 필드가 raw 에 없음") + void requiredMissing() { + RawCommissionDataVO raw = basicRecruitRaw("{\"other\":\"x\"}"); + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y") // 필수 + )); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("REQUIRED"); + assertThat(cap.getValue().getErrorField()).isEqualTo("policy_no"); + } + + @Test + @DisplayName("NO_POLICY: policy_no 매핑이 아예 정의되지 않음") + void noPolicyMapping() { + RawCommissionDataVO raw = basicRecruitRaw("{\"premium\":\"100\"}"); + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("premium", "premium", "DECIMAL", null, null, "N") + )); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("NO_POLICY"); + } + + @Test + @DisplayName("CONTRACT_NOT_FOUND: policy_no 로 계약 매칭 실패") + void contractNotFound() { + RawCommissionDataVO raw = basicRecruitRaw("{\"policyNo\":\"POL-NONE\"}"); + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y") + )); + when(contractMapper.selectList(any(ContractSearchParam.class))).thenReturn(List.of()); + + Long id = engine.transform(raw); + + assertThat(id).isNull(); + ArgumentCaptor cap = ArgumentCaptor.forClass(ParseErrorLogVO.class); + verify(receiveMapper).insertError(cap.capture()); + assertThat(cap.getValue().getErrorType()).isEqualTo("CONTRACT_NOT_FOUND"); + assertThat(cap.getValue().getErrorValue()).isEqualTo("POL-NONE"); + } + + @Test + @DisplayName("CODE_MAP: 매칭 실패 시 원본 값 그대로 사용") + void codeMap_fallback() { + String json = """ + {"pol":"POL-CM", "type":"UNKNOWN_TYPE"} + """; + RawCommissionDataVO raw = basicRecruitRaw(json); + + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"), + mapping("type", "insurance_type", "STRING", "CODE_MAP", "INSURANCE_TYPE", "N") + )); + when(receiveMapper.findTargetCode("SAMSUNG", "INSURANCE_TYPE", "UNKNOWN_TYPE")).thenReturn(null); + setupContractMatch("POL-CM", 500L, 50L); + + engine.transform(raw); + + ArgumentCaptor cap = ArgumentCaptor.forClass(LedgerVO.class); + verify(recruitMapper).insert(cap.capture()); + // 매핑 미존재 → 원본 그대로 + assertThat(cap.getValue().getInsuranceType()).isEqualTo("UNKNOWN_TYPE"); + } + + @Test + @DisplayName("계약값 폴백: premium/pay_method 가 mapped 에 없으면 contract 값 사용") + void buildLedger_fallbackToContract() { + // premium, pay_method 매핑 없음 → contract 의 값(500000, MONTHLY) 사용 + RawCommissionDataVO raw = basicRecruitRaw("{\"pol\":\"POL-FB\"}"); + when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of( + mapping("pol", "policy_no", "STRING", "TRIM", null, "Y") + )); + setupContractMatch("POL-FB", 600L, 60L); + + engine.transform(raw); + + ArgumentCaptor cap = ArgumentCaptor.forClass(LedgerVO.class); + verify(recruitMapper).insert(cap.capture()); + LedgerVO led = cap.getValue(); + assertThat(led.getPremium()).isEqualByComparingTo("500000"); // contract.premium + assertThat(led.getPayMethod()).isEqualTo("MONTHLY"); // contract.payCycle + assertThat(led.getInsuranceType()).isEqualTo("WHOLE_LIFE"); // 기본값 + assertThat(led.getCommissionYear()).isEqualTo(1); // 기본값 + } +} diff --git a/ga-common/src/test/java/com/ga/common/code/CommonCodeServiceTest.java b/ga-common/src/test/java/com/ga/common/code/CommonCodeServiceTest.java new file mode 100644 index 0000000..f6d63ce --- /dev/null +++ b/ga-common/src/test/java/com/ga/common/code/CommonCodeServiceTest.java @@ -0,0 +1,209 @@ +package com.ga.common.code; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * CommonCodeService 단위 테스트. + * + * 주의: @Cacheable / @CacheEvict 는 Spring 프록시 단계에서 동작하므로 + * 단위 테스트(직접 인스턴스 호출)에서는 캐시가 끼지 않는다 — 비즈니스 로직만 검증. + */ +@ExtendWith(MockitoExtension.class) +class CommonCodeServiceTest { + + @Mock CommonCodeMapper mapper; + @InjectMocks CommonCodeService service; + + private CommonCodeVO code(String groupCode, String code, String name) { + CommonCodeVO c = new CommonCodeVO(); + c.setGroupCode(groupCode); + c.setCode(code); + c.setCodeName(name); + return c; + } + + private CommonCodeGroupVO group(String groupCode, String isSystem) { + CommonCodeGroupVO g = new CommonCodeGroupVO(); + g.setGroupCode(groupCode); + g.setIsSystem(isSystem); + return g; + } + + /** 필요한 테스트에서만 호출 (strict stubbing) */ + private void stubGenderGroup() { + when(mapper.selectActiveCodes("GENDER")).thenReturn(List.of( + code("GENDER", "M", "남자"), + code("GENDER", "F", "여자") + )); + } + + @Test + @DisplayName("getCodeName: 매칭 성공") + void getCodeName_hit() { + stubGenderGroup(); + assertThat(service.getCodeName("GENDER", "M")).isEqualTo("남자"); + assertThat(service.getCodeName("GENDER", "F")).isEqualTo("여자"); + } + + @Test + @DisplayName("getCodeName: 미존재 코드 → null") + void getCodeName_miss() { + stubGenderGroup(); + assertThat(service.getCodeName("GENDER", "X")).isNull(); + } + + @Test + @DisplayName("getCodeName: code null → null (DB 조회 안함)") + void getCodeName_nullCode() { + assertThat(service.getCodeName("GENDER", null)).isNull(); + verify(mapper, never()).selectActiveCodes(any()); + } + + @Test + @DisplayName("validateCode: 미존재 시 INVALID_PARAMETER 예외") + void validateCode_invalid() { + stubGenderGroup(); + assertThatThrownBy(() -> service.validateCode("GENDER", "X")) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("validateCode: 존재하면 예외 없음") + void validateCode_ok() { + stubGenderGroup(); + service.validateCode("GENDER", "M"); // no throw + } + + @Test + @DisplayName("getGroup: 미존재 시 NOT_FOUND") + void getGroup_notFound() { + when(mapper.selectGroup("MISSING")).thenReturn(null); + + assertThatThrownBy(() -> service.getGroup("MISSING")) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("createGroup: 중복 그룹 코드 → DUPLICATE_DATA") + void createGroup_duplicate() { + when(mapper.selectGroup("DUP")).thenReturn(group("DUP", "N")); + CommonCodeGroupVO vo = group("DUP", "N"); + + assertThatThrownBy(() -> service.createGroup(vo)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_DATA); + verify(mapper, never()).insertGroup(any()); + } + + @Test + @DisplayName("createGroup: 신규 → insertGroup 호출") + void createGroup_ok() { + when(mapper.selectGroup("NEW")).thenReturn(null); + CommonCodeGroupVO vo = group("NEW", "N"); + + service.createGroup(vo); + + verify(mapper).insertGroup(vo); + } + + @Test + @DisplayName("updateGroup: 시스템 그룹은 SYSTEM_CODE_LOCKED") + void updateGroup_systemLocked() { + when(mapper.selectGroup("SYS")).thenReturn(group("SYS", "Y")); + CommonCodeGroupVO vo = group("SYS", "Y"); + + assertThatThrownBy(() -> service.updateGroup(vo)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED); + verify(mapper, never()).updateGroup(any()); + } + + @Test + @DisplayName("deleteGroup: 일반 그룹은 삭제 가능") + void deleteGroup_ok() { + when(mapper.selectGroup("USER_GRP")).thenReturn(group("USER_GRP", "N")); + + service.deleteGroup("USER_GRP"); + + verify(mapper).deleteGroup("USER_GRP"); + } + + @Test + @DisplayName("deleteGroup: 시스템 그룹은 SYSTEM_CODE_LOCKED") + void deleteGroup_systemLocked() { + when(mapper.selectGroup("SYS")).thenReturn(group("SYS", "Y")); + + assertThatThrownBy(() -> service.deleteGroup("SYS")) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED); + verify(mapper, never()).deleteGroup(any()); + } + + @Test + @DisplayName("createCode: 동일 (group, code) 중복 → DUPLICATE_DATA") + void createCode_duplicate() { + when(mapper.existsByGroupAndCode("GENDER", "M")).thenReturn(1); + + assertThatThrownBy(() -> service.createCode(code("GENDER", "M", "남자"))) + .isInstanceOf(BizException.class); + verify(mapper, never()).insertCode(any()); + } + + @Test + @DisplayName("updateCode: 시스템 그룹의 code/groupCode 는 강제로 원본 유지") + void updateCode_systemForcesIdentity() { + CommonCodeVO old = code("GENDER", "M", "남자"); + old.setCodeId(1L); + when(mapper.selectCode(1L)).thenReturn(old); + when(mapper.selectGroup("GENDER")).thenReturn(group("GENDER", "Y")); // 시스템 그룹 + + // 사용자가 임의로 code/groupCode 바꾸려 시도 + CommonCodeVO update = code("OTHER", "X", "변경시도"); + update.setCodeId(1L); + + service.updateCode(update); + + // 시스템 그룹이므로 code/groupCode 가 원본으로 강제 복원됨 + assertThat(update.getCode()).isEqualTo("M"); + assertThat(update.getGroupCode()).isEqualTo("GENDER"); + verify(mapper).updateCode(update); + } + + @Test + @DisplayName("deleteCode: 시스템 그룹은 SYSTEM_CODE_LOCKED") + void deleteCode_systemLocked() { + CommonCodeVO c = code("GENDER", "M", "남자"); + c.setCodeId(1L); + when(mapper.selectCode(1L)).thenReturn(c); + when(mapper.selectGroup("GENDER")).thenReturn(group("GENDER", "Y")); + + assertThatThrownBy(() -> service.deleteCode(1L)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED); + verify(mapper, never()).deleteCode(any()); + } +} diff --git a/ga-common/src/test/java/com/ga/common/menu/MenuTreeBuilderTest.java b/ga-common/src/test/java/com/ga/common/menu/MenuTreeBuilderTest.java new file mode 100644 index 0000000..8caedc7 --- /dev/null +++ b/ga-common/src/test/java/com/ga/common/menu/MenuTreeBuilderTest.java @@ -0,0 +1,113 @@ +package com.ga.common.menu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuTreeBuilderTest { + + private MenuVO menu(Long id, Long parentId, String code, int sort) { + MenuVO m = new MenuVO(); + m.setMenuId(id); + m.setParentMenuId(parentId); + m.setMenuCode(code); + m.setMenuName(code + "-name"); + m.setMenuType("PAGE"); + m.setSortOrder(sort); + m.setIsActive("Y"); + return m; + } + + @Test + @DisplayName("부모-자식 연결: parent_menu_id 가 null 이면 root") + void buildTree_basic() { + List flat = List.of( + menu(1L, null, "ROOT_A", 1), + menu(2L, 1L, "A_CHILD_1", 2), + menu(3L, 1L, "A_CHILD_2", 1), + menu(4L, null, "ROOT_B", 2) + ); + + List roots = MenuTreeBuilder.build(flat, Map.of()); + + assertThat(roots).hasSize(2); + // root 도 sortOrder 로 정렬됨 + assertThat(roots.get(0).getMenuCode()).isEqualTo("ROOT_A"); + assertThat(roots.get(1).getMenuCode()).isEqualTo("ROOT_B"); + + // ROOT_A 의 자식이 sortOrder 순으로 정렬되었는지 + List aChildren = roots.get(0).getChildren(); + assertThat(aChildren).hasSize(2); + assertThat(aChildren.get(0).getMenuCode()).isEqualTo("A_CHILD_2"); // sort 1 + assertThat(aChildren.get(1).getMenuCode()).isEqualTo("A_CHILD_1"); // sort 2 + assertThat(roots.get(1).getChildren()).isEmpty(); + } + + @Test + @DisplayName("permissionsByMenuId: menuId 별 권한 코드 주입") + void permissions_injected() { + List flat = List.of(menu(10L, null, "M", 1)); + Map> perms = Map.of(10L, List.of("READ", "CREATE", "UPDATE")); + + List roots = MenuTreeBuilder.build(flat, perms); + + assertThat(roots.get(0).getPermissions()).containsExactly("READ", "CREATE", "UPDATE"); + } + + @Test + @DisplayName("permissionsByMenuId 없는 메뉴는 빈 리스트") + void permissions_emptyDefault() { + List flat = List.of(menu(10L, null, "M", 1)); + List roots = MenuTreeBuilder.build(flat, Map.of()); + + assertThat(roots.get(0).getPermissions()).isEmpty(); + } + + @Test + @DisplayName("부모가 목록에 없는 고아 메뉴는 root 로 끌어올림") + void orphan_promotedToRoot() { + List flat = List.of( + menu(1L, null, "ROOT", 1), + menu(99L, 999L, "ORPHAN", 1) // 부모 999가 목록에 없음 + ); + + List roots = MenuTreeBuilder.build(flat, Map.of()); + + // 둘 다 root 로 처리되어야 함 + assertThat(roots).hasSize(2); + assertThat(roots).extracting(MenuResp::getMenuCode).containsExactlyInAnyOrder("ROOT", "ORPHAN"); + } + + @Test + @DisplayName("3단계 계층: 손자까지 연결되고 각 레벨 sortOrder 정렬") + void threeLevels() { + List flat = List.of( + menu(1L, null, "L1", 1), + menu(2L, 1L, "L2", 1), + menu(3L, 2L, "L3_B", 2), + menu(4L, 2L, "L3_A", 1) + ); + + List roots = MenuTreeBuilder.build(flat, Map.of()); + + MenuResp l2 = roots.get(0).getChildren().get(0); + assertThat(l2.getMenuCode()).isEqualTo("L2"); + assertThat(l2.getChildren()).extracting(MenuResp::getMenuCode) + .containsExactly("L3_A", "L3_B"); // sortOrder 정렬 + } + + @Test + @DisplayName("permissionsByMenuId 가 null 이면 권한 주입 스킵") + void permissionsByMenuId_null_skipsInjection() { + List flat = List.of(menu(10L, null, "M", 1)); + + List roots = MenuTreeBuilder.build(flat, null); + + // 기본값(빈 리스트) 그대로 유지 + assertThat(roots.get(0).getPermissions()).isEmpty(); + } +} diff --git a/ga-common/src/test/java/com/ga/common/menu/PermissionCheckerTest.java b/ga-common/src/test/java/com/ga/common/menu/PermissionCheckerTest.java new file mode 100644 index 0000000..b820eee --- /dev/null +++ b/ga-common/src/test/java/com/ga/common/menu/PermissionCheckerTest.java @@ -0,0 +1,57 @@ +package com.ga.common.menu; + +import org.junit.jupiter.api.DisplayName; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionCheckerTest { + + @Mock MenuMapper menuMapper; + @InjectMocks PermissionChecker checker; + + @Test + @DisplayName("userId null 이면 즉시 false 반환 (DB 조회 안함)") + void nullUser_returnsFalse() { + boolean ok = checker.hasPermission(null, "MENU", "READ"); + + assertThat(ok).isFalse(); + verify(menuMapper, never()).hasPermission(any(), any(), any()); + } + + @Test + @DisplayName("Mapper 가 1 이상 반환 → true") + void hasPermission_grant() { + when(menuMapper.hasPermission(1L, "AGENT", "READ")).thenReturn(1); + + assertThat(checker.hasPermission(1L, "AGENT", "READ")).isTrue(); + } + + @Test + @DisplayName("Mapper 가 0 반환 → false") + void hasPermission_deny() { + when(menuMapper.hasPermission(1L, "AGENT", "DELETE")).thenReturn(0); + + assertThat(checker.hasPermission(1L, "AGENT", "DELETE")).isFalse(); + } + + @Test + @DisplayName("Mapper 호출 인자가 그대로 전달됨") + void argumentsForwarded() { + when(menuMapper.hasPermission(anyLong(), any(), any())).thenReturn(1); + + checker.hasPermission(42L, "SETTLE", "APPROVE"); + + verify(menuMapper).hasPermission(42L, "SETTLE", "APPROVE"); + } +}