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<UpdateWrapper<OnlyOfficeSessionEntity>> 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<OnlyOfficeSessionEntity> 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<OnlyOfficeSessionEntity> 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<UpdateWrapper<OnlyOfficeSessionEntity>> 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<UpdateWrapper<OnlyOfficeSessionEntity>> 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<UpdateWrapper<OnlyOfficeSessionEntity>> 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());
|
}
|
}
|
}
|