package com.crm.project.transfer; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.crm.auth.domain.entity.AuthUser; import com.crm.auth.service.IAuthUserService; import com.crm.base.config.MetaObjectFillHandler; import com.crm.base.domain.exception.BusinessErrorException; import com.crm.base.service.UserTransferPort; import com.crm.customer.port.ProjectOwnerSyncPort; import com.crm.project.constant.ProjectConstants; import com.crm.project.domain.entity.CrmProject; import com.crm.project.mapper.CrmProjectMapper; import com.crm.project.port.inbound.impl.ProjectOwnerSyncPortImpl; import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.session.LocalCacheScope; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactoryBuilder; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import java.sql.Connection; import java.sql.Statement; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 移交/同步接缝 H2 集成测试(票 12) * *

断言点对齐票面:

* */ @DisplayName("移交/同步接缝(H2 集成,票 12)") class ProjectSeamsH2IntegrationTest { private static final Long ALICE = 7101L; private static final Long BOB = 7102L; private static final Long CUSTOMER_A = 8001L; private static final Long CUSTOMER_B = 8002L; private static final AtomicBoolean BOOTSTRAPPED = new AtomicBoolean(false); private static SqlSessionFactory sqlSessionFactory; private SqlSession session; private CrmProjectMapper projectMapper; private ProjectUserTransferPortImpl transferPort; private ProjectOwnerSyncPortImpl ownerSyncPort; @BeforeAll static void bootstrap() throws Exception { if (!BOOTSTRAPPED.compareAndSet(false, true)) { return; } JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:projseams;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;" + "DB_CLOSE_DELAY=-1"); MybatisConfiguration configuration = new MybatisConfiguration(); configuration.setEnvironment(new Environment("projseams-test", new JdbcTransactionFactory(), ds)); configuration.setMapUnderscoreToCamelCase(true); configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor(); mpInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); mpInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); configuration.addInterceptor(mpInterceptor); GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler()); GlobalConfigUtils.getGlobalConfig(configuration) .setIdentifierGenerator(DefaultIdentifierGenerator.getInstance()); configuration.addMapper(CrmProjectMapper.class); sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); try (SqlSession s = sqlSessionFactory.openSession(true)) { try (Statement st = s.getConnection().createStatement()) { for (String sql : SCHEMA) { st.execute(sql); } } } } @BeforeEach void openSession() throws Exception { session = sqlSessionFactory.openSession(true); try (Statement st = session.getConnection().createStatement()) { st.execute("DELETE FROM crm_project"); for (String sql : SEED) { st.execute(sql); } } projectMapper = session.getMapper(CrmProjectMapper.class); IAuthUserService authUserService = Mockito.mock(IAuthUserService.class); AuthUser bob = new AuthUser(); bob.setId(BOB); bob.setUsername("李四"); bob.setDeptId(6002L); Mockito.when(authUserService.getById(BOB)).thenReturn(bob); transferPort = new ProjectUserTransferPortImpl(projectMapper, authUserService); ownerSyncPort = new ProjectOwnerSyncPortImpl(projectMapper, authUserService); } @AfterEach void closeSession() { if (session != null) { session.close(); } } @Test @DisplayName("项目域移交:domain()=project(crm-auth 注册表 key 契约);count/transfer 查 crm_project 刷 owner 三列") void transferPortWorks() { assertThat(transferPort.domain()).isEqualTo("project"); assertThat(transferPort.countOwned(ALICE)).isEqualTo(2L); // P1、P2 int updated = transferPort.transferOwned(ALICE, BOB); assertThat(updated).isEqualTo(2); CrmProject p1 = projectMapper.selectById(1L); assertThat(p1.getOwnerUserId()).isEqualTo(BOB); assertThat(p1.getOwnerNameSnapshot()).isEqualTo("李四"); assertThat(p1.getOwnerDeptId()).isEqualTo(6002L); assertThat(transferPort.countOwned(ALICE)).isEqualTo(0L); assertThat(transferPort.countOwned(BOB)).isEqualTo(3L); // P1、P2 移入 + P3 原本就是 BOB } @Test @DisplayName("移交不串扰:商机域(opportunity 表 opp_status 切域)数据不受项目域移交影响") void transferPortIsolation() { // 本测试库无 opportunity 表接入——接缝隔离由「查各自 Mapper/表」保证(结构核查): // 项目域实现断开 extends(父类就是 Object,未继承商机侧共表抽象类; // 二者 instanceof 互查甚至无法通过编译——不相关类型,解耦已由编译器证明) assertThat(transferPort.getClass().getSuperclass()).isEqualTo(Object.class); int updated = transferPort.transferOwned(ALICE, BOB); assertThat(updated).isEqualTo(2); // 仅 crm_project 行受影响 } @Test @DisplayName("ProjectOwnerSyncPort:customer_id 命中批量刷 owner,返回真实影响行数(Noop 已退役)") void ownerSyncByCustomer() { int updated = ownerSyncPort.syncOwnerByCustomer(CUSTOMER_A, BOB); assertThat(updated).isEqualTo(2); // P1、P3 同属客户 A CrmProject p3 = projectMapper.selectById(3L); assertThat(p3.getOwnerUserId()).isEqualTo(BOB); assertThat(p3.getOwnerNameSnapshot()).isEqualTo("李四"); assertThat(p3.getOwnerDeptId()).isEqualTo(6002L); // 客户 B(P2 原 owner=ALICE)也随人走 assertThat(ownerSyncPort.syncOwnerByCustomer(CUSTOMER_B, BOB)).isEqualTo(1); } @Test @DisplayName("owner 同步:接收人缺失 → 业务异常(调用方事务回滚)") void ownerSyncRejectsMissingReceiver() { assertThatThrownBy(() -> ownerSyncPort.syncOwnerByCustomer(CUSTOMER_A, 99999L)) .isInstanceOf(BusinessErrorException.class) .hasFieldOrPropertyWithValue("code", ProjectConstants.CODE_PROJECT_INVALID); } // ==================== 数据 ==================== private static final String[] SCHEMA = { """ CREATE TABLE crm_project ( id BIGINT PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, project_name VARCHAR(200) NOT NULL, project_stage TINYINT NOT NULL DEFAULT 0, project_status TINYINT NOT NULL DEFAULT 1, filing_status TINYINT NOT NULL DEFAULT 1, bid_result TINYINT NOT NULL DEFAULT 0, source_opp_id BIGINT, scheme_card_id BIGINT NOT NULL, customer_id BIGINT NOT NULL, owner_user_id BIGINT, owner_name_snapshot VARCHAR(64), owner_dept_id BIGINT, project_amount DECIMAL(14,2), region_code VARCHAR(32), brand VARCHAR(200), bid_time DATETIME, scheme_budget DECIMAL(14,2), scheme_version VARCHAR(32), scheme_task_status VARCHAR(32), quote_total DECIMAL(14,2), quote_status VARCHAR(32), bid_deadline DATETIME, bid_budget DECIMAL(14,2), brand_control VARCHAR(200), bid_base_type VARCHAR(32), alloc_ratio DECIMAL(5,2), bid_bond DECIMAL(14,2), bid_file_id VARCHAR(64), bidder_name VARCHAR(200), bid_open_time DATETIME, bid_open_place VARCHAR(200), bid_award_method VARCHAR(64), bid_award_amount DECIMAL(14,2), close_type VARCHAR(32), close_reason VARCHAR(500), close_time DATETIME, close_remark VARCHAR(500), version INT NOT NULL DEFAULT 0) """ }; private static final String[] SEED = { "INSERT INTO crm_project (id, deleted, project_name, project_stage, project_status, filing_status, bid_result, scheme_card_id, customer_id, owner_user_id, owner_name_snapshot, owner_dept_id, version) " + "VALUES (1, 0, '项目一', 1, 1, 2, 0, 9001, " + CUSTOMER_A + ", " + ALICE + ", '张三', 6001, 0)", "INSERT INTO crm_project (id, deleted, project_name, project_stage, project_status, filing_status, bid_result, scheme_card_id, customer_id, owner_user_id, owner_name_snapshot, owner_dept_id, version) " + "VALUES (2, 0, '项目二', 3, 1, 2, 0, 9002, " + CUSTOMER_B + ", " + ALICE + ", '张三', 6001, 0)", "INSERT INTO crm_project (id, deleted, project_name, project_stage, project_status, filing_status, bid_result, scheme_card_id, customer_id, owner_user_id, owner_name_snapshot, owner_dept_id, version) " + "VALUES (3, 0, '项目三', 0, 1, 1, 0, 9003, " + CUSTOMER_A + ", " + BOB + ", '王五', 6003, 0)", }; }