package jnpf.elnService.template; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import jnpf.audit.model.AuditEventDTO; import jnpf.elnEntity.template.ElnTemplateBindingEntity; import jnpf.elnEntity.formula.ElnTemplateFormulaBindingEntity; import jnpf.elnEntity.template.ElnTemplateVos; import jnpf.elnMapper.ElnFieldVersionMapper; import jnpf.elnMapper.ElnTemplateBindingMapper; import jnpf.elnService.template.docx.DocxControlSnapshot; import jnpf.util.JsonUtil; import org.junit.Test; import java.lang.reflect.Constructor; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class ElnPendingBindingCleanupServiceTest { private static final Date NOW = new Date(2_000_000L); @Test public void cancelsOnlyExpiredPendingWithoutAControlAndAuditsTheCasWinner() { Fixture fixture = new Fixture(); fixture.add("b1", "tenant-a", "template-1", new Date(1_000_000L)); List result = fixture.service.reconcileExpired( "tenant-a", "template-1", NOW, Collections.emptyList()); assertEquals("CANCELLED", fixture.binding("b1").getBangdingZhuangtai()); assertEquals("PENDING_TIMEOUT_NO_CONTROL", fixture.binding("b1").getZhuangtaiYuanyin()); assertCode(result, "PENDING_TIMEOUT_NO_CONTROL"); assertEquals(1, fixture.audits.size()); assertEquals("ELN_TEMPLATE_BINDING_CANCEL", fixture.audits.get(0).getActionCode()); } @Test public void keepsSingleMatchingControlPendingForPluginConfirmation() { Fixture fixture = new Fixture(); fixture.add("b1", "tenant-a", "template-1", new Date(1_000_000L)); List result = fixture.service.reconcileExpired( "tenant-a", "template-1", NOW, Collections.singletonList(control("eln.field.sample_code", "TEXT", "PRIMARY", Collections.emptyMap()))); assertEquals("PENDING", fixture.binding("b1").getBangdingZhuangtai()); assertCode(result, "PENDING_CONFIRMATION_REQUIRED"); assertTrue(fixture.audits.isEmpty()); } @Test public void ignoresUnobservableTextBusinessConfig() { Fixture fixture = new Fixture(); fixture.add("text", "tenant-a", "template-1", new Date(1_000_000L)); Map fieldConfig = new LinkedHashMap<>(); fieldConfig.put("placeholder", "请输入"); fieldConfig.put("defaultValue", "默认文本"); fixture.configs.put("text", fieldConfig); List result = fixture.service.reconcileExpired( "tenant-a", "template-1", NOW, Collections.singletonList(control("eln.field.sample_code", "TEXT", "PRIMARY", Collections.emptyMap()))); assertBindingCode(result, "text", "PENDING_CONFIRMATION_REQUIRED"); assertNoBindingCode(result, "text", "CONFIG_MISMATCH"); } @Test public void canonicalizesDropdownOptionsAndReportsRealDifference() { Fixture fixture = new Fixture(); fixture.add("same", "tenant-a", "template-1", new Date(1_000_000L)); fixture.binding("same").setKongjianBiaoqian("eln.field.same"); fixture.binding("same").setKongjianLeixing("DROPDOWN"); fixture.configs.put("same", dropdownConfig()); fixture.add("different", "tenant-a", "template-1", new Date(1_000_000L)); fixture.binding("different").setKongjianBiaoqian("eln.field.different"); fixture.binding("different").setKongjianLeixing("DROPDOWN"); fixture.configs.put("different", dropdownConfig()); Map reordered = new LinkedHashMap<>(); reordered.put("FAIL", "不合格"); reordered.put("PASS", "合格"); Map different = new LinkedHashMap<>(); different.put("PASS", "合格"); different.put("FAIL", "异常"); List result = fixture.service.reconcileExpired( "tenant-a", "template-1", NOW, Arrays.asList( control("eln.field.same", "DROPDOWN", "PRIMARY", reordered), control("eln.field.different", "DROPDOWN", "PRIMARY", different))); assertBindingCode(result, "same", "PENDING_CONFIRMATION_REQUIRED"); assertNoBindingCode(result, "same", "CONFIG_MISMATCH"); assertBindingCode(result, "different", "CONFIG_MISMATCH"); } @Test public void reportsDuplicateRoleTypeAndConfigurationMismatchWithoutCancelling() { Fixture fixture = new Fixture(); fixture.add("duplicate", "tenant-a", "template-1", new Date(1_000_000L)); fixture.add("role", "tenant-a", "template-1", new Date(1_000_000L)); fixture.binding("role").setKongjianBiaoqian("eln.field.role"); fixture.add("type", "tenant-a", "template-1", new Date(1_000_000L)); fixture.binding("type").setKongjianBiaoqian("eln.field.type"); fixture.add("config", "tenant-a", "template-1", new Date(1_000_000L)); fixture.binding("config").setKongjianBiaoqian("eln.field.config"); fixture.binding("config").setKongjianLeixing("DROPDOWN"); fixture.configs.put("config", dropdownConfig()); List controls = new ArrayList<>(); controls.add(control("eln.field.sample_code", "TEXT", "PRIMARY", Collections.emptyMap())); controls.add(control("eln.field.sample_code", "TEXT", "PRIMARY", Collections.emptyMap())); controls.add(control("eln.field.role", "TEXT", "REFERENCE", Collections.emptyMap())); controls.add(control("eln.field.type", "DATE", "PRIMARY", Collections.emptyMap())); Map differentOptions = new LinkedHashMap<>(); differentOptions.put("PASS", "合格"); differentOptions.put("FAIL", "异常"); controls.add(control("eln.field.config", "DROPDOWN", "PRIMARY", differentOptions)); List result = fixture.service.reconcileExpired( "tenant-a", "template-1", NOW, controls); assertCode(result, "DUPLICATE_TAG"); assertCode(result, "ROLE_MISMATCH"); assertCode(result, "TYPE_MISMATCH"); assertCode(result, "CONFIG_MISMATCH"); assertTrue(fixture.audits.isEmpty()); assertTrue(fixture.bindings.values().stream() .allMatch(item -> "PENDING".equals(item.getBangdingZhuangtai()))); } @Test public void tenantCannotReconcileAnotherTenantAndCopiedPendingNeverExpires() { Fixture fixture = new Fixture(); fixture.add("tenant-b", "tenant-b", "template-1", new Date(1_000_000L)); fixture.add("copied", "tenant-a", "template-1", null); fixture.service.reconcileExpired("tenant-a", "template-1", NOW, Collections.emptyList()); assertEquals("PENDING", fixture.binding("tenant-b").getBangdingZhuangtai()); assertEquals("PENDING", fixture.binding("copied").getBangdingZhuangtai()); assertTrue(fixture.audits.isEmpty()); } @Test public void twoInstancesProduceOnlyOneCancellationAudit() { Fixture fixture = new Fixture(); fixture.add("b1", "tenant-a", "template-1", new Date(1_000_000L)); ElnPendingBindingCleanupService second = new ElnPendingBindingCleanupService(fixture.store, fixture.audits::add); fixture.service.reconcileExpired("tenant-a", "template-1", NOW, Collections.emptyList()); second.reconcileExpired("tenant-a", "template-1", NOW, Collections.emptyList()); assertEquals(1, fixture.audits.size()); } @Test public void cancellationCasExcludesLogicallyDeletedBindings() throws Exception { TableInfoHelper.initTableInfo(new MapperBuilderAssistant( new MybatisConfiguration(), "eln-pending-cleanup-test"), ElnTemplateBindingEntity.class); final Wrapper[] captured = new Wrapper[1]; ElnTemplateBindingMapper mapper = (ElnTemplateBindingMapper) Proxy.newProxyInstance( ElnTemplateBindingMapper.class.getClassLoader(), new Class[]{ElnTemplateBindingMapper.class}, (proxy, method, args) -> { if ("update".equals(method.getName())) { captured[0] = (Wrapper) args[1]; return 1; } return null; }); Constructor constructor = Class.forName( "jnpf.elnService.template.ElnPendingBindingCleanupService$MyBatisStore") .getDeclaredConstructor(ElnTemplateBindingMapper.class, ElnFieldVersionMapper.class); constructor.setAccessible(true); ElnPendingBindingCleanupService.Store store = (ElnPendingBindingCleanupService.Store) constructor.newInstance(mapper, null); ElnTemplateBindingEntity binding = new ElnTemplateBindingEntity(); binding.setId("b1"); binding.setFTenantId("tenant-a"); binding.setMubanId("template-1"); assertTrue(store.cancelIfPending(binding, 1, NOW, "timeout")); String sql = captured[0].getSqlSegment().toLowerCase(Locale.ROOT); assertTrue(sql, sql.contains("f_delete_mark") && sql.contains("is null")); } @Test public void reconcilesExpiredFormulaPendingFromAuthoritativeLockSnapshot() { FormulaCleanupStore formulaStore = new FormulaCleanupStore(); formulaStore.add("unlocked", "result-unlocked", new Date(1_000_000L)); formulaStore.add("locked", "result-locked", new Date(1_000_000L)); formulaStore.add("partial", "result-partial", new Date(1_000_000L)); List audits = new ArrayList<>(); ElnPendingBindingCleanupService service = new ElnPendingBindingCleanupService( new Fixture().store, formulaStore, audits::add); List result = service.reconcileExpired( "tenant-a", "template-1", NOW, Arrays.asList( lockControl("eln.field.result-unlocked", null), lockControl("eln.field.result-unlocked-ref", null), lockControl("eln.field.result-locked", "sdtContentLocked"), lockControl("eln.field.result-locked-ref", "sdtContentLocked"), lockControl("eln.field.result-partial", "sdtContentLocked"))); assertEquals("CANCELLED", formulaStore.bindings.get("unlocked").getBangdingZhuangtai()); assertEquals("PENDING", formulaStore.bindings.get("locked").getBangdingZhuangtai()); assertEquals("PENDING", formulaStore.bindings.get("partial").getBangdingZhuangtai()); assertBindingCode(result, "unlocked", "PENDING_TIMEOUT_NO_LOCK"); assertBindingCode(result, "locked", "PENDING_CONFIRMATION_REQUIRED"); assertBindingCode(result, "partial", "FORMULA_RESULT_LOCK_MISMATCH"); assertEquals(1, audits.stream().filter(item -> "ELN_TEMPLATE_FORMULA_CANCEL".equals(item.getActionCode())).count()); AuditEventDTO cancellation = audits.stream().filter(item -> "ELN_TEMPLATE_FORMULA_CANCEL".equals(item.getActionCode())).findFirst().get(); @SuppressWarnings("unchecked") Map snapshot = JsonUtil.getJsonToBean( cancellation.getDataSnapshot(), Map.class); assertTrue(snapshot.containsKey("before")); assertTrue(snapshot.containsKey("after")); } @Test public void keepsFormulaPendingWhenPrimaryIsUnlockedButReferenceIsLocked() { FormulaCleanupStore formulaStore = new FormulaCleanupStore(); formulaStore.add("partial-unlocked", "result-partial-unlocked", new Date(1_000_000L)); List audits = new ArrayList<>(); ElnPendingBindingCleanupService service = new ElnPendingBindingCleanupService( new Fixture().store, formulaStore, audits::add); List result = service.reconcileExpired( "tenant-a", "template-1", NOW, Arrays.asList( lockControl("eln.field.result-partial-unlocked", null), lockControl("eln.field.result-partial-unlocked-ref", "sdtContentLocked"))); assertEquals("PENDING", formulaStore.bindings.get("partial-unlocked").getBangdingZhuangtai()); assertBindingCode(result, "partial-unlocked", "FORMULA_RESULT_LOCK_MISMATCH"); assertTrue(audits.isEmpty()); } private static DocxControlSnapshot lockControl(String tag, String lock) { return new DocxControlSnapshot(tag, null, lock, "TEXT", "PRIMARY", "word/document.xml", "OK", Collections.emptyMap()); } private static DocxControlSnapshot control(String tag, String type, String role, Map config) { return new DocxControlSnapshot(tag, null, null, type, role, "word/document.xml", "OK", config); } private static void assertCode(List diagnostics, String code) { assertTrue("缺少诊断: " + code, diagnostics.stream().anyMatch(item -> code.equals(item.getCode()))); } private static void assertBindingCode(List diagnostics, String bindingId, String code) { assertTrue("缺少诊断: " + bindingId + "/" + code, diagnostics.stream().anyMatch(item -> bindingId.equals(item.getBindingId()) && code.equals(item.getCode()))); } private static void assertNoBindingCode(List diagnostics, String bindingId, String code) { assertTrue("不应存在诊断: " + bindingId + "/" + code, diagnostics.stream().noneMatch(item -> bindingId.equals(item.getBindingId()) && code.equals(item.getCode()))); } private static Map dropdownConfig() { Map result = new LinkedHashMap<>(); result.put("controlType", "dropdown"); result.put("placeholder", "请选择"); result.put("defaultOptionValue", "PASS"); Map pass = new LinkedHashMap<>(); pass.put("display", "合格"); pass.put("value", "PASS"); Map fail = new LinkedHashMap<>(); fail.put("display", "不合格"); fail.put("value", "FAIL"); result.put("options", Arrays.asList(pass, fail)); return result; } private static final class Fixture { private final Map bindings = new LinkedHashMap<>(); private final Map> configs = new LinkedHashMap<>(); private final List audits = new ArrayList<>(); private final MemoryStore store = new MemoryStore(bindings, configs); private final ElnPendingBindingCleanupService service = new ElnPendingBindingCleanupService(store, audits::add); private void add(String id, String tenant, String template, Date expiresAt) { ElnTemplateBindingEntity item = new ElnTemplateBindingEntity(); item.setId(id); item.setFTenantId(tenant); item.setMubanId(template); item.setZiduanDingyiId("field-1"); item.setZiduanBanbenId("version-1"); item.setZiduanBianma("sample_code"); item.setKongjianBiaoqian("eln.field.sample_code"); item.setKongjianLeixing("TEXT"); item.setKongjianJuese("PRIMARY"); item.setBangdingZhuangtai("PENDING"); item.setDaoqiShijian(expiresAt); item.setFVersion(1); bindings.put(id, item); configs.put(id, Collections.emptyMap()); } private ElnTemplateBindingEntity binding(String id) { return bindings.get(id); } } private static final class MemoryStore implements ElnPendingBindingCleanupService.Store { private final Map bindings; private final Map> configs; private MemoryStore(Map bindings, Map> configs) { this.bindings = bindings; this.configs = configs; } @Override public List listExpired( String tenantId, String templateId, Date now) { List result = new ArrayList<>(); for (ElnTemplateBindingEntity item : bindings.values()) { if (tenantId.equals(item.getFTenantId()) && templateId.equals(item.getMubanId()) && "PENDING".equals(item.getBangdingZhuangtai()) && item.getDaoqiShijian() != null && item.getDaoqiShijian().before(now)) { result.add(item); } } return result; } @Override public Map expectedControlConfig( String tenantId, ElnTemplateBindingEntity binding) { return configs.get(binding.getId()); } @Override public boolean cancelIfPending(ElnTemplateBindingEntity binding, int expectedVersion, Date now, String reason) { ElnTemplateBindingEntity current = bindings.get(binding.getId()); if (current == null || !binding.getFTenantId().equals(current.getFTenantId()) || !"PENDING".equals(current.getBangdingZhuangtai()) || current.getFVersion() != expectedVersion || current.getDaoqiShijian() == null || !current.getDaoqiShijian().before(now)) { return false; } current.setBangdingZhuangtai("CANCELLED"); current.setZhuangtaiYuanyin(reason); current.setFVersion(expectedVersion + 1); return true; } } private static final class FormulaCleanupStore implements ElnPendingBindingCleanupService.FormulaStore { private final Map bindings = new LinkedHashMap<>(); private final Map> resultControls = new LinkedHashMap<>(); private void add(String id, String resultId, Date expiresAt) { ElnTemplateFormulaBindingEntity item = new ElnTemplateFormulaBindingEntity(); item.setId(id); item.setFTenantId("tenant-a"); item.setMubanId("template-1"); item.setBangdingZhuangtai("PENDING"); item.setJieguoBangdingId(resultId); item.setDaoqiShijian(expiresAt); item.setFVersion(1); item.setCaozuoBianma("op-" + id); bindings.put(id, item); List controls = new ArrayList<>(); controls.add(fieldBinding(resultId, "eln.field." + resultId, "PRIMARY", null)); controls.add(fieldBinding(resultId + "-ref", "eln.field." + resultId + "-ref", "REFERENCE", resultId)); resultControls.put(id, controls); } @Override public List listExpired( String tenantId, String templateId, Date now) { List result = new ArrayList<>(); for (ElnTemplateFormulaBindingEntity item : bindings.values()) if (item.getDaoqiShijian() != null && item.getDaoqiShijian().before(now) && "PENDING".equals(item.getBangdingZhuangtai())) result.add(item); return result; } @Override public List resultControls( String tenantId, String templateId, String formulaBindingId) { return resultControls.get(formulaBindingId); } @Override public boolean cancelIfPending(ElnTemplateFormulaBindingEntity item, int expectedVersion, Date now, String reason) { if (!"PENDING".equals(item.getBangdingZhuangtai()) || item.getFVersion() != expectedVersion) return false; item.setBangdingZhuangtai("CANCELLED"); item.setZhuangtaiYuanyin(reason); item.setFVersion(expectedVersion + 1); return true; } private static ElnTemplateBindingEntity fieldBinding(String id, String tag, String role, String primary) { ElnTemplateBindingEntity result = new ElnTemplateBindingEntity(); result.setId(id); result.setKongjianBiaoqian(tag); result.setKongjianJuese(role); result.setYinyongShiliId(primary); result.setBangdingZhuangtai("ACTIVE"); return result; } } }