UNPKG

@wordpress/sync

Version:
696 lines (579 loc) 17.2 kB
/** * External dependencies */ import * as Y from 'yjs'; import * as fun from 'lib0/function'; import { describe, expect, it, jest, beforeEach, afterEach, } from '@jest/globals'; /** * Internal dependencies */ import { createSyncManager } from '../manager'; import { CRDT_RECORD_MAP_KEY, WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, } from '../config'; import { createPersistedCRDTDoc } from '../persistence'; import { getProviderCreators } from '../providers'; import type { CRDTDoc, ObjectData, ProviderCreator, ProviderCreatorResult, RecordHandlers, SyncConfig, } from '../types'; // Mock dependencies. jest.mock( '../providers', () => ( { getProviderCreators: jest.fn(), } ) ); const mockGetProviderCreators = jest.mocked( getProviderCreators ); describe( 'SyncManager', () => { let mockHandlers: jest.MockedObject< RecordHandlers >; let mockProviderCreator: jest.Mock< ProviderCreator >; let mockProviderResult: ProviderCreatorResult; let mockRecord: ObjectData; let mockSyncConfig: jest.MockedObject< SyncConfig >; beforeEach( () => { // Reset all mocks jest.clearAllMocks(); mockRecord = { id: '123', title: 'Test Post', }; mockProviderResult = { destroy: jest.fn(), }; mockProviderCreator = jest.fn( () => Promise.resolve( mockProviderResult ) ); mockGetProviderCreators.mockReturnValue( [ mockProviderCreator ] ); mockSyncConfig = { applyChangesToCRDTDoc: jest.fn(), getChangesFromCRDTDoc: jest.fn( ( ydoc: CRDTDoc, editedRecord: ObjectData ) => { const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); // Simple deep equality check. return Object.fromEntries( Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => ! fun.equalityDeep( editedRecord[ key ], newValue ) ) ); } ), supports: {}, }; mockHandlers = { editRecord: jest.fn(), getEditedRecord: jest.fn( async () => Promise.resolve( mockRecord ) ), saveRecord: jest.fn( async () => Promise.resolve() ), }; } ); afterEach( () => { jest.restoreAllMocks(); } ); describe( 'load', () => { it( 'creates a sync manager with load method', () => { const manager = createSyncManager(); expect( manager ).toHaveProperty( 'load' ); expect( typeof manager.load ).toBe( 'function' ); } ); it( 'loads an entity and applies changes to CRDT document', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Verify that applyChangesToCRDTDoc was called with the record data expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); } ); it( 'creates providers for the entity', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledWith( 'post', '123', expect.any( Y.Doc ) ); } ); it( 'does not load entity when no providers are available', async () => { mockGetProviderCreators.mockReturnValue( [] ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockSyncConfig.applyChangesToCRDTDoc ).not.toHaveBeenCalled(); expect( mockProviderCreator ).not.toHaveBeenCalled(); } ); it( 'does not load entity twice if already loaded', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Should only be called once despite two load attempts expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); it( 'loads multiple entities independently', async () => { const manager = createSyncManager(); const record1 = { id: '123', title: 'Post 1' }; const record2 = { id: '456', title: 'Post 2' }; await manager.load( mockSyncConfig, 'post', '123', record1, mockHandlers ); await manager.load( mockSyncConfig, 'post', '456', record2, mockHandlers ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 2 ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 2 ); } ); describe( 'persisted CRDT doc behavior', () => { function createRecordWithPersistedCRDTDoc( record: ObjectData, persistedRecord: ObjectData = record ): ObjectData { const persistedDoc = new Y.Doc(); const persistedRecordMap = persistedDoc.getMap( CRDT_RECORD_MAP_KEY ); Object.entries( persistedRecord ).forEach( ( [ key, value ] ) => { persistedRecordMap.set( key, value ); } ); const persistedMeta = createPersistedCRDTDoc( persistedDoc ); persistedDoc.destroy(); return { ...record, meta: { ...record.meta, ...persistedMeta, }, }; } it( 'applies the current record when no persisted CRDT doc exists', async () => { mockSyncConfig = { ...mockSyncConfig, supports: { crdtPersistence: true }, }; const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Current record should be applied as changes since the persisted doc does not exist. expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); // Changes should be correctly applied. const mockCall = mockSyncConfig.applyChangesToCRDTDoc.mock.calls[ 0 ]; const targetDoc = mockCall[ 0 ] as Y.Doc; const appliedChanges = mockCall[ 1 ] as ObjectData; expect( targetDoc.getMap( CRDT_RECORD_MAP_KEY ).get( 'title' ) ).toBeUndefined(); expect( appliedChanges.title ).toStrictEqual( 'Test Post' ); // getChangesFromCRDTDoc should not be called since there was no persisted doc. expect( mockSyncConfig.getChangesFromCRDTDoc ).not.toHaveBeenCalled(); // Verify a save operation occurred. expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); expect( mockHandlers.editRecord ).toHaveBeenCalledWith( { meta: { [ WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: expect.any( String ), }, } ); expect( mockHandlers.saveRecord ).toHaveBeenCalledTimes( 1 ); } ); it( 'applies a valid persisted CRDT doc without applying the current record', async () => { const record = createRecordWithPersistedCRDTDoc( mockRecord ); mockSyncConfig = { ...mockSyncConfig, supports: { crdtPersistence: true }, }; const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', record, mockHandlers ); // Current record should NOT be applied since the persisted doc is valid. expect( mockSyncConfig.applyChangesToCRDTDoc ).not.toHaveBeenCalled(); // getChangesFromCRDTDoc should be called with the persisted doc and record. expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), record ); // Verify no save operation occurred expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); expect( mockHandlers.saveRecord ).not.toHaveBeenCalled(); } ); it( 'applies an invalid persisted CRDT doc, then applies the current record', async () => { const record = createRecordWithPersistedCRDTDoc( mockRecord, { title: 'Title from persisted CRDT doc', } ); mockSyncConfig = { ...mockSyncConfig, supports: { crdtPersistence: true }, }; const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', record, mockHandlers ); // Current record should be applied since the persisted doc is invalid. expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), record ); // Changes should be correctly applied. const mockCall = mockSyncConfig.applyChangesToCRDTDoc.mock.calls[ 0 ]; const targetDoc = mockCall[ 0 ] as Y.Doc; const appliedChanges = mockCall[ 1 ] as ObjectData; expect( targetDoc.getMap( CRDT_RECORD_MAP_KEY ).get( 'title' ) ).toStrictEqual( 'Title from persisted CRDT doc' ); expect( appliedChanges.title ).toStrictEqual( 'Test Post' ); // getChangesFromCRDTDoc should be called with the persisted doc and record. expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), record ); // Verify a save operation occurred. expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); expect( mockHandlers.editRecord ).toHaveBeenCalledWith( { meta: { [ WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: expect.any( String ), }, } ); expect( mockHandlers.saveRecord ).toHaveBeenCalledTimes( 1 ); } ); it( 'ignores a persisted CRDT doc when CRDT persistence is not supported', async () => { const record = createRecordWithPersistedCRDTDoc( mockRecord, { title: 'Persisted Title', } ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', record, mockHandlers ); // Current record should be applied since the persisted doc does not exist. expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), record ); // Changes should be correctly applied. const mockCall = mockSyncConfig.applyChangesToCRDTDoc.mock.calls[ 0 ]; const targetDoc = mockCall[ 0 ] as Y.Doc; const appliedChanges = mockCall[ 1 ] as ObjectData; expect( targetDoc.getMap( CRDT_RECORD_MAP_KEY ).get( 'title' ) ).toBeUndefined(); expect( appliedChanges.title ).toStrictEqual( 'Test Post' ); // getChangesFromCRDTDoc should not be called since the persisted doc is igored. expect( mockSyncConfig.getChangesFromCRDTDoc ).not.toHaveBeenCalled(); // Verify a save operation occurred. expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); expect( mockHandlers.editRecord ).toHaveBeenCalledWith( { meta: {}, } ); expect( mockHandlers.saveRecord ).toHaveBeenCalledTimes( 1 ); } ); } ); } ); describe( 'unload', () => { it( 'unloads an entity and destroys its state', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); manager.unload( 'post', '123' ); expect( mockProviderResult.destroy ).toHaveBeenCalled(); } ); it( 'does not throw when unloading non-existent entity', () => { const manager = createSyncManager(); expect( () => { manager.unload( 'post', '999' ); } ).not.toThrow(); } ); it( 'allows reloading after unloading', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); manager.unload( 'post', '123' ); jest.clearAllMocks(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); it( 'unloads specific entity without affecting others', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); await manager.load( mockSyncConfig, 'post', '456', mockRecord, mockHandlers ); manager.unload( 'post', '123' ); // Only one provider should be destroyed expect( mockProviderResult.destroy ).toHaveBeenCalledTimes( 1 ); // Should still be able to update the other entity jest.clearAllMocks(); manager.update( 'post', '456', { title: 'Updated' }, 'local' ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled(); } ); } ); describe( 'update', () => { it( 'updates CRDT document with local changes', async () => { const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); jest.clearAllMocks(); const changes = { title: 'Updated Title' }; manager.update( 'post', '123', changes, 'local-editor' ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), changes ); } ); it( 'does not update when entity is not loaded', () => { const manager = createSyncManager(); const changes = { title: 'Updated Title' }; manager.update( 'post', '999', changes, 'local-editor' ); expect( mockSyncConfig.applyChangesToCRDTDoc ).not.toHaveBeenCalled(); } ); it( 'applies changes with specified origin', async () => { // Capture the Y.Doc from provider creator let capturedDoc: Y.Doc | null = null; mockProviderCreator.mockImplementation( async ( _objectType: string, _objectId: string, ydoc: Y.Doc ) => { capturedDoc = ydoc; return mockProviderResult; } ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Get the captured Y.Doc expect( capturedDoc ).not.toBeNull(); // Spy on transact to verify origin is passed const transactSpy = jest.spyOn( capturedDoc as unknown as Y.Doc, 'transact' ); const changes = { title: 'Updated Title' }; const customOrigin = 'custom-origin'; manager.update( 'post', '123', changes, customOrigin ); expect( transactSpy ).toHaveBeenCalledWith( expect.any( Function ), customOrigin ); } ); } ); describe( 'CRDT doc observation', () => { it( 'edits the local entity record when remote updates arrive', async () => { // Capture the Y.Doc from provider creator. let capturedDoc: Y.Doc | null = null; mockProviderCreator.mockImplementation( async ( _objectType: string, _objectId: string, ydoc: Y.Doc ) => { capturedDoc = ydoc; return mockProviderResult; } ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Clear calls of editRecord, which is called during load. mockHandlers.editRecord.mockClear(); expect( capturedDoc ).not.toBeNull(); // Simulate a remote change. const remoteDoc = new Y.Doc(); remoteDoc .getMap( CRDT_RECORD_MAP_KEY ) .set( 'title', 'Title from remote peer' ); Y.applyUpdateV2( capturedDoc as unknown as Y.Doc, Y.encodeStateAsUpdateV2( remoteDoc ) ); remoteDoc.destroy(); // Wait a tick. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); expect( mockHandlers.editRecord ).toHaveBeenCalledWith( { title: 'Title from remote peer', } ); } ); it( 'does not edit the local record for local transactions', async () => { // Capture the Y.Doc from provider creator. let capturedDoc: Y.Doc | null = null; mockProviderCreator.mockImplementation( async ( _objectType: string, _objectId: string, ydoc: Y.Doc ) => { capturedDoc = ydoc; return mockProviderResult; } ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Clear calls of editRecord, which is called during load. mockHandlers.editRecord.mockClear(); expect( capturedDoc ).not.toBeNull(); const ydoc = capturedDoc as unknown as Y.Doc; const recordMap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); // Clear previous calls jest.clearAllMocks(); // Simulate a local update with sync manager origin ydoc.transact( () => { recordMap.set( 'title', 'Local Update' ); } ); // Wait a tick. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); // Should not trigger record update for local sync manager origin expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); } ); } ); } );