package jnpf.dmsService; import jakarta.validation.Validation; import jakarta.validation.ValidatorFactory; import jnpf.base.UserInfo; import jnpf.dmsEntity.storage.approval.*; import jnpf.dmsEntity.storage.form.*; import jnpf.dmsEntity.storage.form.DmsApprovedRegistrationCallback.StoredFile; import jnpf.dmsEntity.storage.vo.DmsFileUploadVO; import jnpf.dmsMapper.*; import jnpf.dmsPermission.DmsCurrentIdentity; import jnpf.dmsService.center.DmsCenterDirectoryService; import jnpf.dmsService.center.DmsCenterQueryService; import jnpf.dmsService.center.DmsCenterRegistrationService; import jnpf.exception.DataException; import jnpf.flowable.WorkFlowApi; import jnpf.flowable.entity.TaskEntity; import org.junit.jupiter.api.*; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class DmsApprovedFileAcceptanceServiceTest { static ValidatorFactory validation; @BeforeAll static void openValidation() { validation = Validation.buildDefaultValidatorFactory(); } @AfterAll static void closeValidation() { validation.close(); } DmsApprovedAuditSource source = mock(DmsApprovedAuditSource.class); DmsFileUploadService uploads = mock(DmsFileUploadService.class); DmsFileRevisionService revisions = mock(DmsFileRevisionService.class); DmsApprovedBatchMapper receipts = mock(DmsApprovedBatchMapper.class); DmsVersionStateMapper states = mock(DmsVersionStateMapper.class); DmsCurrentIdentity identity = mock(DmsCurrentIdentity.class); DmsCenterDirectoryService directories = mock(DmsCenterDirectoryService.class); DmsCenterRegistrationService center = new DmsCenterRegistrationService(uploads, revisions, states); DmsCenterQueryService centerQueries = mock(DmsCenterQueryService.class); WorkFlowApi workflow = mock(WorkFlowApi.class); DmsQianpiyeRenderSnapshotService qianpiyeSnapshots = mock(DmsQianpiyeRenderSnapshotService.class); DmsApprovedVersionPropertiesService approvedProperties = mock(DmsApprovedVersionPropertiesService.class); DmsVersionPropertiesMapper versionProperties = mock(DmsVersionPropertiesMapper.class); DmsBusinessLifecycleGuard businessOccupancy = mock(DmsBusinessLifecycleGuard.class); DmsApprovedFileAcceptanceService service = new DmsApprovedFileAcceptanceService( source, directories, center, centerQueries, receipts, workflow, validation.getValidator(), qianpiyeSnapshots, approvedProperties, versionProperties, businessOccupancy); Date approved = new Date(); UUID batchId = UUID.randomUUID(); TaskEntity task = new TaskEntity(); DmsApprovedRegistrationForm command = new DmsApprovedRegistrationForm(); UUID v7() { UUID value=UUID.randomUUID(); return new UUID((value.getMostSignificantBits() & ~0xF000L) | 0x7000L,value.getLeastSignificantBits()); } @BeforeEach void approval() { when(receipts.nextUuidV7()).thenAnswer(i -> v7().toString()); command.setSourceId("flow"); command.setTaskId("task"); command.setAuditType("xinzeng_shenhe"); task.setId("task"); task.setFlowId("flow"); task.setStatus(2); task.setEndTime(approved); when(workflow.getInfoSubmit("task")).thenReturn(task); UserInfo user = new UserInfo(); user.setUserId("actor"); when(identity.requireUser()).thenReturn(user); when(identity.tenantId(user)).thenReturn("tenant"); } DmsApprovedBatch arrange(DmsApprovedItem... items) { when(approvedProperties.create(eq("author"), nullable(String.class))) .thenReturn(new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode() .put("prepared_by_name", "制定人")); DmsApprovedBatch b = new DmsApprovedBatch(); b.setId(batchId); b.setStatus("REGISTERING"); b.setTenantId("tenant"); b.setSourceDigest("digest"); b.setItems(Arrays.asList(items)); b.setLeaseToken("attempt"); b.setScheduledAt(approved); b.setApprovedAt(approved); b.setActorId("actor"); b.setAuditId("audit"); b.setFlowId("flow"); b.setTaskId("task"); b.setAuditType(command.getAuditType()); when(source.load("tenant", "flow", "task", command.getAuditType(), "actor", approved)) .thenReturn(new DmsApprovedAuditSource.Source(b, "author", approved, false)); when(receipts.lock("tenant", batchId)).thenReturn(b); when(receipts.items("tenant", batchId)).thenReturn(Arrays.asList(items)); return b; } DmsApprovedItem item(String key, String type) { DmsApprovedItem i = new DmsApprovedItem(); i.setTenantId("tenant"); i.setSourceKey("main".equals(key) ? "audit:zhuwenjian" : key); i.setChangeType(type); i.setDocumentNo(key); i.setDocumentName(key); i.setDepartmentId("dept"); i.setFolderId(UUID.randomUUID().toString()); i.setVersionLabel("D(00)"); i.setFileJson("[{\"url\":\"/api/file/Image/annex/" + key + ".docx\"}]"); i.setShifouShoukong("main".equals(key) ? "yes" : "no"); i.setDocumentId(v7()); i.setFileVersionId(v7()); if (!"CREATE".equals(type)) i.setBaseFileVersionId(UUID.randomUUID()); if ("REUSE".equals(type)) i.setFileVersionId(i.getBaseFileVersionId()); return i; } DmsApprovedRegistrationCallback callback(DmsApprovedItem... items) { DmsApprovedRegistrationCallback form = new DmsApprovedRegistrationCallback(); form.setTenantId("tenant"); form.setAttemptToken("attempt"); form.setBatchId(batchId); form.setSourceDigest("digest"); for (DmsApprovedItem item : items) { StoredFile f = new StoredFile(); f.setSourceKey(item.getSourceKey()); f.setFileJson(item.getFileJson()); DmsFileUploadRegisterForm c = new DmsFileUploadRegisterForm(); c.setDocumentId(item.getDocumentId() == null ? UUID.randomUUID() : item.getDocumentId()); c.setFileVersionId(item.getFileVersionId()); c.setFileObjectId(UUID.randomUUID()); c.setChecksumSha256("a".repeat(64)); c.setSizeBytes(12L); c.setFileName("a.docx"); c.setDocumentType("DOCX"); c.setStoragePlatform("seaweedfs-1"); c.setBucketName("dms"); c.setObjectPath("dms/path/"); c.setObjectName("a.docx"); f.setContent(c); form.getFiles().add(f); } return form; } void successfulRegistration() { when(receipts.registered("tenant", batchId)).thenReturn(1); when(receipts.storeChecksum(eq("tenant"), eq(batchId), any(), any())).thenReturn(1); when(uploads.registerApproved(any(), eq("jnpf-file"), eq("flow"), eq("task"), any(UserInfo.class))).thenAnswer(inv -> { DmsFileUploadRegisterForm req = inv.getArgument(0); return result(req.getDocumentId(), req.getFileVersionId(), req.getChecksumSha256()); }); when(revisions.registerApproved(any(), eq("jnpf-file"), eq("flow"), eq("task"), any(UserInfo.class))).thenAnswer(inv -> { DmsFileRevisionRegisterForm req = inv.getArgument(0); return result(req.getDocumentId(), req.getFileVersionId(), req.getChecksumSha256()); }); when(states.approvePending(eq("tenant"), any(), eq(approved), eq(approved), isNull(), eq("actor"))).thenReturn(1); } DmsFileUploadVO result(UUID documentId, UUID versionId, String checksum) { DmsFileUploadVO r = new DmsFileUploadVO(); r.setDocumentId(documentId); r.setFileVersionId(versionId); r.setChecksumSha256(checksum); return r; } DmsApprovedBatch stage() { return service.stage("tenant", "flow", "task", command.getAuditType(), "actor", approved); } @Test void occupancyFailureStopsPreparingBeforeReservedIdsOrItemsAreWritten() { arrange(item("main","CREATE")); doThrow(new DataException("occupancy changed")).when(businessOccupancy).requireAuditSource(any()); assertThrows(DataException.class,this::stage); verify(receipts,never()).nextUuidV7();verify(receipts,never()).insertItem(any()); verifyNoInteractions(uploads,revisions,states,directories); } @Test void occupancyFailureStopsCallbackBeforeCenterRegistration() { var main=item("main","CREATE");arrange(main); doThrow(new DataException("occupancy changed")).when(businessOccupancy).requireAuditSource(any()); assertThrows(DataException.class,()->service.accept(callback(main),"jnpf-file")); verifyNoInteractions(uploads,revisions,states,directories); verify(receipts,never()).registered(any(),any()); } @Test void recordsBothProgressTablesBeforeAnyCenterRegistration() { DmsApprovedBatch b = arrange(item("main", "CREATE"), item("record", "CREATE")); assertSame(b, stage()); assertEquals("REGISTERING", b.getStatus()); verify(receipts).insertBatch(b); verify(receipts, times(2)).insertItem(any()); verifyNoInteractions(uploads, revisions, states); } @Test void rejectsUnapprovedWrongTaskWrongFlowAndMissingApprovalDate() { task.setStatus(1); assertThrows(DataException.class, () -> stage()); task.setStatus(2); task.setId("other"); assertThrows(DataException.class, () -> stage()); task.setId("task"); task.setFlowId("other"); assertThrows(DataException.class, () -> stage()); task.setFlowId("flow"); task.setEndTime(null); assertThrows(DataException.class, () -> stage()); verifyNoInteractions(source, uploads, revisions, states); } @Test void rejectsChangedControlFlagAgainstPersistedTaskBeforeRegistration() { DmsApprovedItem nativeItem = item("main", "CREATE"); arrange(nativeItem); DmsApprovedItem saved = item("main", "CREATE"); saved.setDocumentId(nativeItem.getDocumentId()); saved.setFileVersionId(nativeItem.getFileVersionId()); saved.setShifouShoukong("no"); when(receipts.items("tenant", batchId)).thenReturn(List.of(saved)); assertThrows(DataException.class, () -> service.accept(callback(nativeItem), "jnpf-file")); verifyNoInteractions(uploads, revisions, states); } @Test void rejectsReviewAndUntrustedService() { command.setAuditType("fushen"); assertThrows(DataException.class, () -> stage()); command.setAuditType("xinzeng_shenhe"); assertThrows(DataException.class, () -> service.accept(callback(), "jnpf-other")); verifyNoInteractions(source); } @Test void confirmsCompleteSetAndUsesNativeAttributes() { DmsApprovedItem main = item("main", "CREATE"), row = item("record", "CREATE"); arrange(main, row); DmsApprovedRegistrationCallback form = callback(main, row); form.getFiles().get(0).getContent().setDocumentName("untrusted name"); form.getFiles().get(0).getContent().setExtendedProperties( new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode().put("prepared_by_name", "伪造人员")); form.getFiles().get(0).getContent().setShifouShoukong("no"); form.getFiles().get(1).getContent().setShifouShoukong("yes"); successfulRegistration(); DmsApprovedBatch receipt = service.accept(form, "jnpf-file"); assertEquals("WAITING", receipt.getStatus()); assertEquals("main", form.getFiles().get(0).getContent().getDocumentName()); assertEquals("制定人", form.getFiles().get(0).getContent().getExtendedProperties().path("prepared_by_name").asText()); verify(approvedProperties).create("author", null); assertEquals("author", form.getFiles().get(0).getContent().getPreparedBy()); assertEquals("yes", form.getFiles().get(0).getContent().getShifouShoukong()); assertEquals("no", form.getFiles().get(1).getContent().getShifouShoukong()); verify(uploads, times(2)).registerApproved(any(), eq("jnpf-file"), eq("flow"), eq("task"), any(UserInfo.class)); verify(states, times(2)).approvePending(eq("tenant"), any(), eq(approved), eq(approved), isNull(), eq("actor")); verify(receipts).registered("tenant", batchId); verify(receipts, times(2)).storeChecksum(eq("tenant"), eq(batchId), any(), any()); verify(directories).bind(eq("tenant"), eq(main.getDocumentId()), eq(List.of(row.getDocumentId()))); verify(receipts, never()).insertBatch(any()); verify(receipts, never()).insertItem(any()); verifyNoInteractions(revisions); } @Test void revisionCanMixUpgradeNewRecordAndReuseWithoutRequiringANewDocumentNumberForUpgrade() { command.setAuditType("xiuding_shenhe"); DmsApprovedItem main = item("main", "REVISE"), row = item("record", "CREATE"), reused = item("retained", "REUSE"); main.setDocumentNo(null); arrange(main, row, reused); successfulRegistration(); DmsApprovedBatch b = service.accept(callback(main, row), "jnpf-file"); assertEquals(3, b.getItems().size()); verify(revisions).registerApproved(any(), eq("jnpf-file"), eq("flow"), eq("task"), any(UserInfo.class)); verify(uploads).registerApproved(any(), eq("jnpf-file"), eq("flow"), eq("task"), any(UserInfo.class)); verify(receipts, never()).storeChecksum(eq("tenant"), eq(batchId), eq("retained"), any()); } @Test void existingVersionRetryUsesFrozenSnapshotWithoutResolvingCurrentPersonnel() { var main = item("main", "CREATE"); arrange(main); successfulRegistration(); var existing = new jnpf.dmsEntity.storage.vo.DmsVersionProperties(); existing.setId(main.getFileVersionId()); var frozen = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode().put("prepared_by_name", "登记时姓名"); existing.setExtendedProperties(frozen); when(versionProperties.find("tenant", main.getFileVersionId())).thenReturn(existing); var form = callback(main); service.accept(form, "jnpf-file"); assertEquals(frozen, form.getFiles().get(0).getContent().getExtendedProperties()); verifyNoInteractions(approvedProperties); } @Test void allReusedBatchStillHasDurableDateCarrier() { arrange(item("main", "REUSE")); when(receipts.registered("tenant", batchId)).thenReturn(1); DmsApprovedBatch receipt = service.accept(callback(), "jnpf-file"); assertNotNull(receipt.getId()); verify(receipts).registered("tenant", batchId); verifyNoInteractions(approvedProperties, versionProperties); verify(businessOccupancy).requireAuditSource(any()); verifyNoInteractions(uploads, revisions, states); } @Test void missingExtraDuplicateAndMismatchedFileFailBeforeAnyMetadataWrite() { DmsApprovedItem main = item("main", "CREATE"), row = item("record", "CREATE"); arrange(main, row); assertThrows(DataException.class, () -> service.accept(callback(main), "jnpf-file")); assertThrows(DataException.class, () -> service.accept(callback(main, row, item("extra", "CREATE")), "jnpf-file")); assertThrows(DataException.class, () -> service.accept(callback(main, row, row), "jnpf-file")); DmsApprovedRegistrationCallback changed = callback(main, row); changed.getFiles().get(1).setFileJson("changed"); assertThrows(DataException.class, () -> service.accept(changed, "jnpf-file")); verifyNoInteractions(uploads, revisions, states); verify(receipts, never()).insertBatch(any()); } @Test void changedSourceOrApprovalDuringStorageCannotBeConfirmed() { DmsApprovedItem main = item("main", "CREATE"); arrange(main); DmsApprovedRegistrationCallback changed = callback(main); changed.setSourceDigest("stale"); assertThrows(DataException.class, () -> service.accept(changed, "jnpf-file")); task.setStatus(1); assertThrows(DataException.class, () -> service.accept(callback(main), "jnpf-file")); verifyNoInteractions(uploads, revisions, states); } @Test void validatesAllStorageFactsBeforeWritingFirstItem() { DmsApprovedItem main = item("main", "CREATE"), row = item("record", "CREATE"); arrange(main, row); DmsApprovedRegistrationCallback form = callback(main, row); form.getFiles().get(1).getContent().setChecksumSha256("bad"); assertThrows(DataException.class, () -> service.accept(form, "jnpf-file")); verifyNoInteractions(uploads, revisions, states); } @Test void duplicateAcceptedCallbackDoesNotRegisterAgain() { DmsApprovedBatch b = arrange(item("main", "CREATE")); b.setStatus("WAITING"); assertSame(b, service.accept(callback(), "jnpf-file")); verifyNoInteractions(uploads, revisions, states); verify(receipts, never()).registered(any(), any()); } @Test void registrationRetryReconstructsSourceWithTheSameReservedIds() { DmsApprovedBatch b = arrange(item("main", "CREATE")); when(receipts.find("tenant", "task", "xinzeng_shenhe")).thenReturn(b); assertSame(b, stage()); assertEquals(b.getId(), b.getItems().get(0).getBatchId()); verifyNoInteractions(uploads, revisions, states); verify(receipts, never()).insertBatch(any()); } @Test void appliedReceiptIsIdempotentWithoutReadingChangedBusinessOutput() { DmsApprovedBatch b = arrange(item("main", "CREATE")); b.setStatus("APPLIED"); when(receipts.find("tenant", "task", "xinzeng_shenhe")).thenReturn(b); assertSame(b, stage()); assertSame(b, service.accept(callback(), "jnpf-file")); verifyNoInteractions(uploads, revisions, states); DmsApprovedRegistrationCallback altered = callback(); altered.setSourceDigest("other"); assertThrows(DataException.class, () -> service.accept(altered, "jnpf-file")); } @Test void callbackCannotCreateRegistrationProgressOrChangeAssignedVersionIds() { assertThrows(DataException.class, () -> service.accept(callback(), "jnpf-file")); DmsApprovedItem i = item("main", "CREATE"); arrange(i); DmsApprovedRegistrationCallback form = callback(i); form.getFiles().get(0).getContent().setFileVersionId(UUID.randomUUID()); assertThrows(DataException.class, () -> service.accept(form, "jnpf-file")); verifyNoInteractions(uploads, revisions, states); verify(receipts, never()).insertBatch(any()); } @Test void legacyReservationCanOnlyBeRepairedBeforeAnyMetadataExists() { DmsApprovedItem saved = item("main", "CREATE"); saved.setDocumentId(UUID.randomUUID()); saved.setFileVersionId(UUID.randomUUID()); UUID oldDocument = saved.getDocumentId(), oldVersion = saved.getFileVersionId(); DmsApprovedBatch old = arrange(saved); DmsApprovedItem fresh = item("main","CREATE"); DmsApprovedBatch nativeBatch = new DmsApprovedBatch(); nativeBatch.setSourceDigest("digest"); nativeBatch.setScheduledAt(approved); nativeBatch.setItems(List.of(fresh)); when(source.load("tenant", "flow", "task", "xinzeng_shenhe", "actor", approved)) .thenReturn(new DmsApprovedAuditSource.Source(nativeBatch,"author",approved,false)); when(receipts.find("tenant", "task", "xinzeng_shenhe")).thenReturn(old); assertThrows(DataException.class,this::stage); when(receipts.repairReservation(any(),eq(oldDocument),eq(oldVersion))).thenReturn(1); assertEquals(7,stage().getItems().get(0).getDocumentId().version()); assertEquals(7,old.getItems().get(0).getFileVersionId().version()); verify(receipts,never()).insertBatch(any()); verify(receipts,never()).insertItem(any()); } @Test void retryRepairsUnregisteredDocumentVersionIdCollision() { DmsApprovedItem saved = item("main", "CREATE"); saved.setFileVersionId(saved.getDocumentId()); UUID oldId = saved.getDocumentId(); DmsApprovedBatch old = arrange(saved); DmsApprovedItem fresh = item("main", "CREATE"); DmsApprovedBatch nativeBatch = new DmsApprovedBatch(); nativeBatch.setSourceDigest("digest"); nativeBatch.setScheduledAt(approved); nativeBatch.setItems(List.of(fresh)); when(source.load("tenant", "flow", "task", "xinzeng_shenhe", "actor", approved)) .thenReturn(new DmsApprovedAuditSource.Source(nativeBatch,"author",approved,false)); when(receipts.find("tenant", "task", "xinzeng_shenhe")).thenReturn(old); when(receipts.repairReservation(any(),eq(oldId),eq(oldId))).thenReturn(1); DmsApprovedItem repaired = stage().getItems().get(0); assertEquals(oldId, repaired.getDocumentId()); assertNotEquals(oldId, repaired.getFileVersionId()); verify(receipts,never()).insertBatch(any()); } @Test void staleExecutorOrWrongTenantCannotConfirmFiles() { DmsApprovedItem main = item("main", "CREATE"); arrange(main); DmsApprovedRegistrationCallback request = callback(main); request.setAttemptToken("expired-owner"); assertThrows(DataException.class, () -> service.accept(request, "jnpf-file")); request.setAttemptToken("attempt"); request.setTenantId("other-tenant"); assertThrows(DataException.class, () -> service.accept(request, "jnpf-file")); verifyNoInteractions(uploads,revisions,states); } @Test void retryRejectsChangedSourceWithoutReplacingReservedIds() { DmsApprovedItem main = item("main", "CREATE"); DmsApprovedBatch old = arrange(main); when(receipts.find("tenant", "task", "xinzeng_shenhe")).thenReturn(old); DmsApprovedBatch changed = new DmsApprovedBatch(); changed.setSourceDigest("different"); changed.setItems(List.of(main)); when(source.load("tenant", "flow", "task", "xinzeng_shenhe", "actor", approved)) .thenReturn(new DmsApprovedAuditSource.Source(changed,"author",approved,false)); assertThrows(DataException.class,this::stage); verify(receipts,never()).insertItem(any()); verify(receipts,never()).prepared(any()); } @Test void springCommitsDmsProgressBeforePushAndRollsBackAFailedBatchCallback() { class Transactions extends org.springframework.transaction.support.AbstractPlatformTransactionManager { final List events = new ArrayList<>(); protected Object doGetTransaction() { return new Object(); } protected void doBegin(Object transaction, org.springframework.transaction.TransactionDefinition definition) { events.add("begin"); } protected void doCommit(org.springframework.transaction.support.DefaultTransactionStatus status) { events.add("commit"); } protected void doRollback(org.springframework.transaction.support.DefaultTransactionStatus status) { events.add("rollback"); } } Transactions transactions = new Transactions(); org.springframework.aop.framework.ProxyFactory proxy = new org.springframework.aop.framework.ProxyFactory(service); proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(transactions, new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); DmsApprovedFileAcceptanceService transactional = (DmsApprovedFileAcceptanceService) proxy.getProxy(); DmsApprovedItem main = item("main", "CREATE"), row = item("record", "CREATE"); DmsApprovedBatch b = arrange(main, row); when(source.load("tenant", "flow", "task", "xinzeng_shenhe", "actor", approved)).thenAnswer(inv -> { assertTrue(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()); return new DmsApprovedAuditSource.Source(b, "author", approved, false); }); transactional.stage("tenant", "flow", "task", "xinzeng_shenhe", "actor", approved); assertEquals(Arrays.asList("begin", "commit"), transactions.events); assertFalse(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive(), "DMS must commit registration progress and release source locks before pushing to file"); batchId = b.getId(); when(receipts.lock("tenant", batchId)).thenReturn(b); when(receipts.items("tenant", batchId)).thenReturn(Arrays.asList(main, row)); successfulRegistration(); when(states.approvePending(eq("tenant"), any(), eq(approved), eq(approved), isNull(), eq("actor"))) .thenReturn(1, 0); assertThrows(DataException.class, () -> transactional.accept(callback(main, row), "jnpf-file")); assertEquals(Arrays.asList("begin", "commit", "begin", "rollback"), transactions.events); verify(receipts).insertBatch(b); verify(receipts, never()).registered(any(), any()); assertFalse(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()); } }