package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeSessionEntity; import jnpf.bizcommon.onlyoffice.mapper.OnlyOfficeSessionMapper; import jnpf.exception.DataException; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.Before; import org.junit.Test; import org.springframework.dao.DuplicateKeyException; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.Date; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class OnlyOfficeRecordRevisionFenceServiceTest { private OnlyOfficeSessionMapper mapper; private OnlyOfficeFreezeCancellationStore cancellationStore; private OnlyOfficeRecordRevisionFenceService service; private OnlyOfficeProperties properties; @Before public void setUp() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), OnlyOfficeSessionEntity.class); mapper = mock(OnlyOfficeSessionMapper.class); cancellationStore = mock(OnlyOfficeFreezeCancellationStore.class); properties = new OnlyOfficeProperties(); properties.setLockTimeoutMinutes(30); service = new OnlyOfficeRecordRevisionFenceService(mapper, cancellationStore, properties); } @Test public void idleRecordSessionCanBeAtomicallyClaimedForRetirement() { OnlyOfficeSessionEntity existing = session(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(mapper.selectOne(any())).thenReturn(existing); AtomicReference> update = new AtomicReference<>(); when(mapper.update(isNull(), any())).thenAnswer(invocation -> { update.set(invocation.getArgument(1)); return 1; }); String token = service.acquire("tenant-1", "record-1", "record.docx", "annex"); assertNotNull(token); assertFalse(token.trim().isEmpty()); update.get().getSqlSegment(); assertTrue(update.get().getParamNameValuePairs().containsValue( OnlyOfficeSessionEntity.SessionStatus.IDLE)); assertTrue(update.get().getParamNameValuePairs().containsValue(token)); assertTrue(update.get().getSqlSet().contains("session_status")); assertTrue(update.get().getSqlSet().contains("publish_token")); } @Test public void missingSessionIsInsertedDirectlyAsRetiring() { when(mapper.selectOne(any())).thenReturn(null); AtomicReference inserted = new AtomicReference<>(); when(mapper.insert(any(OnlyOfficeSessionEntity.class))).thenAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return 1; }); String token = service.acquire("tenant-1", "record-1", "record.docx", "annex"); assertEquals("tenant-1", inserted.get().getTenantId()); assertEquals("record-1", inserted.get().getBizDataId()); assertEquals("eln.record", inserted.get().getBizModule()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING), inserted.get().getSessionStatus()); assertEquals(token, inserted.get().getPublishToken()); assertNull(inserted.get().getLockUserId()); } @Test public void callerOperationCanRetryAcquireWithoutReplacingItsExistingFence() { OnlyOfficeSessionEntity existing = session(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING); existing.setBizModule("eln.record"); existing.setBizDataId("record-1"); existing.setPublishToken("freeze:operation-1"); when(mapper.selectOne(any())).thenReturn(existing); String token = service.acquireForOperation( "tenant-1", "record-1", "record.docx", "annex", "operation-1"); assertEquals("freeze:operation-1", token); verify(mapper, never()).update(isNull(), any()); verify(mapper, never()).insert(any(OnlyOfficeSessionEntity.class)); } @Test public void releaseBeforeAcquirePersistsCancellationAndPreventsLateFence() { AtomicReference inserted = new AtomicReference<>(); when(mapper.selectOne(any())).thenAnswer(invocation -> inserted.get()); when(mapper.insert(any(OnlyOfficeSessionEntity.class))).thenAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return 1; }); when(mapper.update(isNull(), any())).thenReturn(0); assertTrue(service.release("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1")); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), inserted.get().getSessionStatus()); assertEquals("cancelled:freeze:operation-1", inserted.get().getPublishToken()); expectDataException(() -> service.acquireForOperation( "tenant-1", "record-1", "record.docx", "annex", "operation-1")); } @Test public void releaseLosesIdleCasThenReleasesFenceWonByConcurrentAcquire() { OnlyOfficeSessionEntity idle = session(OnlyOfficeSessionEntity.SessionStatus.IDLE); OnlyOfficeSessionEntity retiring = session(OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING); retiring.setPublishToken("freeze:operation-1"); when(mapper.selectOne(any())).thenReturn(idle, retiring); AtomicInteger updates = new AtomicInteger(); when(mapper.update(isNull(), any())).thenAnswer(invocation -> updates.incrementAndGet() == 3 ? 1 : 0); assertTrue(service.release("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1")); assertEquals(3, updates.get()); } @Test public void cancellationStoreBlocksLateAcquireAfterAnotherSessionBecomesIdle() { OnlyOfficeSessionEntity editing = session(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(mapper.selectOne(any())).thenReturn(editing); when(mapper.update(isNull(), any())).thenReturn(0); assertTrue(service.release("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1")); verify(cancellationStore).cancel("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1"); when(cancellationStore.isCancelled("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1")).thenReturn(true); expectDataException(() -> service.acquireForOperation( "tenant-1", "record-1", "record.docx", "annex", "operation-1")); } @Test public void cancellationArrivingAfterIdleCasReleasesTheJustAcquiredFence() { when(mapper.selectOne(any())).thenReturn(session(OnlyOfficeSessionEntity.SessionStatus.IDLE)); when(mapper.update(isNull(), any())).thenReturn(1); when(cancellationStore.isCancelled("tenant-1", "record-1", "record.docx", "annex", "freeze:operation-1")).thenReturn(false, false, true); expectDataException(() -> service.acquireForOperation( "tenant-1", "record-1", "record.docx", "annex", "operation-1")); org.mockito.Mockito.verify(mapper, org.mockito.Mockito.times(2)).update(isNull(), any()); } @Test public void nonIdleSessionsRejectRevisionFence() { OnlyOfficeSessionEntity activeEditing = session(OnlyOfficeSessionEntity.SessionStatus.EDITING); activeEditing.setLockUserId("user-1"); activeEditing.setLockHeartbeat(new Date()); int[] blocked = { OnlyOfficeSessionEntity.SessionStatus.SAVING, OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED, OnlyOfficeSessionEntity.SessionStatus.PUBLISHING, OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING, OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL }; when(mapper.selectOne(any())).thenReturn(activeEditing); expectDataException(() -> service.acquire( "tenant-1", "record-1", "record.docx", "annex")); for (int status : blocked) { when(mapper.selectOne(any())).thenReturn(session(status)); expectDataException(() -> service.acquire( "tenant-1", "record-1", "record.docx", "annex")); } verify(mapper, never()).update(isNull(), any()); } @Test public void expiredEditingSessionCanBeAtomicallyClaimedForRetirement() { OnlyOfficeSessionEntity expired = session(OnlyOfficeSessionEntity.SessionStatus.EDITING); expired.setLockUserId("user-1"); expired.setLockHeartbeat(new Date(System.currentTimeMillis() - 31L * 60L * 1000L)); when(mapper.selectOne(any())).thenReturn(expired); AtomicReference> update = new AtomicReference<>(); when(mapper.update(isNull(), any())).thenAnswer(invocation -> { update.set(invocation.getArgument(1)); return 1; }); String token = service.acquire("tenant-1", "record-1", "record.docx", "annex"); assertNotNull(token); update.get().getSqlSegment(); assertTrue(update.get().getParamNameValuePairs().containsValue( OnlyOfficeSessionEntity.SessionStatus.EDITING)); assertTrue(update.get().getSqlSet().contains("session_status")); assertTrue(update.get().getSqlSet().contains("lock_heartbeat")); } @Test public void concurrentFirstOpenIsRereadAndRejected() { when(mapper.selectOne(any())).thenReturn(null, session(OnlyOfficeSessionEntity.SessionStatus.EDITING)); when(mapper.insert(any(OnlyOfficeSessionEntity.class))) .thenThrow(new DuplicateKeyException("duplicate file")); expectDataException(() -> service.acquire( "tenant-1", "record-1", "record.docx", "annex")); } @Test public void completeAndReleaseRequireTheExactRetiringToken() { AtomicReference> finish = new AtomicReference<>(); when(mapper.update(isNull(), any())).thenAnswer(invocation -> { finish.set(invocation.getArgument(1)); return 1; }); assertTrue(service.complete( "tenant-1", "record-1", "record.docx", "annex", "token-1")); finish.get().getSqlSegment(); assertTrue(finish.get().getParamNameValuePairs().containsValue("token-1")); assertTrue(finish.get().getParamNameValuePairs().containsValue( OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING)); assertTrue(finish.get().getSqlSet().contains("session_status")); assertTrue(finish.get().getSqlSet().contains("publish_token")); assertTrue(service.release( "tenant-1", "record-1", "record.docx", "annex", "token-2")); when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(0); assertFalse(service.complete( "tenant-1", "record-1", "record.docx", "annex", "old-token")); } @Test public void documentUpdateReleaseRotatesTheOnlyOfficeDocumentKey() { OnlyOfficeSessionEntity existing = session( OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING); existing.setPublishToken("token-1"); when(mapper.selectOne(any())).thenReturn(existing); AtomicReference> update = new AtomicReference<>(); when(mapper.update(isNull(), any())).thenAnswer(invocation -> { update.set(invocation.getArgument(1)); return 1; }); assertTrue(service.releaseAfterDocumentUpdate( "tenant-1", "record-1", "record.docx", "annex", "token-1")); update.get().getSqlSegment(); assertTrue(update.get().getSqlSet().contains("version_no")); assertTrue(update.get().getSqlSet().contains("doc_key")); assertTrue(update.get().getSqlSet().contains("last_save_time")); assertTrue(update.get().getParamNameValuePairs().containsValue(2)); assertTrue(update.get().getParamNameValuePairs().containsValue( DigestUtil.md5Hex("record.docx_2"))); assertTrue(update.get().getParamNameValuePairs().containsValue( OnlyOfficeSessionEntity.SessionStatus.IDLE)); } private static OnlyOfficeSessionEntity session(int status) { OnlyOfficeSessionEntity session = new OnlyOfficeSessionEntity(); session.setId("session-1"); session.setTenantId("tenant-1"); session.setFileId("record.docx"); session.setFileType("annex"); session.setBizModule("eln.record"); session.setBizDataId("record-1"); session.setDocKey("doc-key-1"); session.setVersionNo(1); session.setVersion(3); session.setSessionStatus(status); return session; } private static void expectDataException(Runnable action) { try { action.run(); fail("预期抛出 DataException"); } catch (DataException expected) { assertFalse(expected.getMessage().trim().isEmpty()); } } }