/** * External dependencies */ import * as Y from 'yjs'; import { Awareness } from 'y-protocols/awareness'; 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, CRDT_STATE_MAP_KEY, CRDT_STATE_MAP_SAVED_AT_KEY as SAVED_AT_KEY, CRDT_STATE_MAP_SAVED_BY_KEY as SAVED_BY_KEY, } from '../config'; import { getProviderCreators } from '../providers'; import type { CRDTDoc, ObjectData, ProviderCreator, ProviderCreatorResult, RecordHandlers, SyncConfig, } from '../types'; import { serializeCrdtDoc } from '../utils'; // 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', meta: {}, }; mockProviderResult = { destroy: jest.fn(), on: 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 ) ) ); } ), createAwareness: jest.fn( ( ydoc: Y.Doc ) => new Awareness( ydoc ) ), getPersistedCRDTDoc: jest.fn( () => null ), }; mockHandlers = { addUndoMeta: jest.fn(), editRecord: jest.fn(), getEditedRecord: jest.fn( async () => Promise.resolve( mockRecord ) ), onStatusChange: jest.fn(), persistCRDTDoc: jest.fn(), refetchRecord: jest.fn( async () => Promise.resolve() ), restoreUndoMeta: jest.fn(), }; } ); 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, 'postType/post', '123', mockRecord, mockHandlers ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledWith( { objectType: 'postType/post', objectId: '123', ydoc: expect.any( Y.Doc ), awareness: expect.any( Awareness ), } ); } ); 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 createPersistedCRDTDoc( persistedRecord: ObjectData ): string { const persistedDoc = new Y.Doc(); const persistedRecordMap = persistedDoc.getMap( CRDT_RECORD_MAP_KEY ); Object.entries( persistedRecord ).forEach( ( [ key, value ] ) => { persistedRecordMap.set( key, value ); } ); return serializeCrdtDoc( persistedDoc ); } it( 'applies the current record when no persisted CRDT doc exists', async () => { 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 ); // getChangesFromCRDTDoc should not be called since there was no persisted doc. expect( mockSyncConfig.getChangesFromCRDTDoc ).not.toHaveBeenCalled(); // Verify that the CRDT doc was persisted. expect( mockHandlers.persistCRDTDoc ).toHaveBeenCalledTimes( 1 ); } ); it( 'accepts a valid persisted CRDT doc without applying changes', async () => { mockSyncConfig = { ...mockSyncConfig, getPersistedCRDTDoc: jest.fn( () => createPersistedCRDTDoc( mockRecord ) ), }; const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Changes 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 ), mockRecord ); // Verify that the CRDT doc was persisted. expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); } ); it( 'applies a persisted CRDT doc with invalidated fields, then applies changes', async () => { mockSyncConfig = { ...mockSyncConfig, getPersistedCRDTDoc: jest.fn( () => createPersistedCRDTDoc( { ...mockRecord, title: 'Invalidated title from persisted CRDT doc', } ) ), }; const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); // Changes should be applied for the invalidated properties. const expectedChanges = { title: mockRecord.title, }; expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), expectedChanges ); // getChangesFromCRDTDoc should be called with the persisted doc and record. expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockSyncConfig.getChangesFromCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); // Verify that the CRDT doc was persisted. expect( mockHandlers.persistCRDTDoc ).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' ); // Wait a tick for yieldToEventLoop. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled(); } ); } ); describe( 'update', () => { it( 'updates CRDT document with local changes', async () => { // Capture the Y.Doc from provider creator let capturedDoc: Y.Doc | null = null; mockProviderCreator.mockImplementation( async ( { ydoc } ) => { capturedDoc = ydoc; return mockProviderResult; } ); 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' ); // Wait a tick for yieldToEventLoop. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); // Verify that applyChangesToCRDTDoc was called with the changes. expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), changes ); // Verify that the record metadata was not updated. const ydoc = capturedDoc as unknown as Y.Doc; const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); expect( stateMap.get( SAVED_AT_KEY ) ).toBeUndefined(); expect( stateMap.get( SAVED_BY_KEY ) ).toBeUndefined(); } ); it( 'does not update when entity is not loaded', async () => { const manager = createSyncManager(); const changes = { title: 'Updated Title' }; manager.update( 'post', '999', changes, 'local-editor' ); // Wait a tick for yieldToEventLoop. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); 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 ( { ydoc } ) => { 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 ); // Wait a tick for yieldToEventLoop. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); expect( transactSpy ).toHaveBeenCalledWith( expect.any( Function ), customOrigin ); } ); it( 'updates the record metadata when the update is associated with a save', async () => { // Capture the Y.Doc from provider creator. let capturedDoc: Y.Doc | null = null; mockProviderCreator.mockImplementation( async ( { ydoc } ) => { capturedDoc = ydoc; return mockProviderResult; } ); const manager = createSyncManager(); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); jest.clearAllMocks(); const changes = { title: 'Updated Title' }; const now = Date.now(); manager.update( 'post', '123', changes, 'local-editor', { isSave: true, } ); // Wait a tick for yieldToEventLoop. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); // Verify that applyChangesToCRDTDoc was called with the changes. expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), changes ); // Verify that the record metadata was updated. const ydoc = capturedDoc as unknown as Y.Doc; const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); expect( stateMap.get( SAVED_AT_KEY ) ).toBeGreaterThanOrEqual( now ); expect( stateMap.get( SAVED_BY_KEY ) ).toBe( ydoc.clientID ); } ); } ); describe( 'shouldSync', () => { it( 'skips loading entity when shouldSync returns false', async () => { const manager = createSyncManager(); mockSyncConfig.shouldSync = jest.fn( () => false ); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'post', '123' ); expect( mockSyncConfig.applyChangesToCRDTDoc ).not.toHaveBeenCalled(); expect( mockProviderCreator ).not.toHaveBeenCalled(); } ); it( 'loads entity when shouldSync returns true', async () => { const manager = createSyncManager(); mockSyncConfig.shouldSync = jest.fn( () => true ); await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'post', '123' ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); it( 'loads entity when shouldSync is not defined', async () => { const manager = createSyncManager(); delete mockSyncConfig.shouldSync; await manager.load( mockSyncConfig, 'post', '123', mockRecord, mockHandlers ); expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledTimes( 1 ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); it( 'skips loading collection when shouldSync returns false', async () => { const manager = createSyncManager(); mockSyncConfig.shouldSync = jest.fn( () => false ); const mockCollectionHandlers = { onStatusChange: jest.fn(), refetchRecords: jest.fn( async () => Promise.resolve() ), }; await manager.loadCollection( mockSyncConfig, 'comment', mockCollectionHandlers ); expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'comment', null ); expect( mockProviderCreator ).not.toHaveBeenCalled(); } ); it( 'loads collection when shouldSync returns true', async () => { const manager = createSyncManager(); mockSyncConfig.shouldSync = jest.fn( () => true ); const mockCollectionHandlers = { onStatusChange: jest.fn(), refetchRecords: jest.fn( async () => Promise.resolve() ), }; await manager.loadCollection( mockSyncConfig, 'comment', mockCollectionHandlers ); expect( mockSyncConfig.shouldSync ).toHaveBeenCalledWith( 'comment', null ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); it( 'loads collection when shouldSync is not defined', async () => { const manager = createSyncManager(); delete mockSyncConfig.shouldSync; const mockCollectionHandlers = { onStatusChange: jest.fn(), refetchRecords: jest.fn( async () => Promise.resolve() ), }; await manager.loadCollection( mockSyncConfig, 'comment', mockCollectionHandlers ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); } ); } ); 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 ( { ydoc } ) => { 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 ( { ydoc } ) => { 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(); } ); } ); } );