@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
634 lines (500 loc) • 15 kB
text/typescript
/**
* WordPress dependencies
*/
import { Y } from '@wordpress/sync';
/**
* External dependencies
*/
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
/**
* Internal dependencies
*/
import {
CRDT_RECORD_MAP_KEY,
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
} from '../../sync';
import {
applyPostChangesToCRDTDoc,
getPostChangesFromCRDTDoc,
type PostChanges,
type YPostRecord,
} from '../crdt';
import type { YBlock, YBlocks } from '../crdt-blocks';
import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils';
import type { Post, Type } from '../../entity-types';
describe( 'crdt', () => {
let doc: Y.Doc;
beforeEach( () => {
doc = new Y.Doc();
jest.clearAllMocks();
} );
afterEach( () => {
doc.destroy();
} );
describe( 'applyPostChangesToCRDTDoc', () => {
const mockPostType = {} as Type;
let map: YMapWrap< YPostRecord >;
beforeEach( () => {
map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY );
} );
it( 'applies simple property changes', () => {
const changes = {
title: 'New Title',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( 'New Title' );
} );
it( 'does not sync disallowed properties', () => {
const changes = {
title: 'New Title',
unsyncedProperty: 'value',
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( map.has( 'unsyncedProperty' ) ).toBe( false );
expect( map.get( 'title' )?.toString() ).toBe( 'New Title' );
} );
it( 'does not sync function values', () => {
const changes = {
title: () => 'function value',
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( map.has( 'title' ) ).toBe( false );
} );
it( 'handles title with RenderedText format', () => {
const changes = {
title: { raw: 'Raw Title', rendered: 'Rendered Title' },
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( 'Raw Title' );
} );
it( 'skips "Auto Draft" template title when no current value exists', () => {
const changes = {
title: 'Auto Draft',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( '' );
} );
it( 'skips "Auto Draft" template title when current value is empty Y.Text', () => {
// First set an empty title (simulates a prior sync that cleared it).
applyPostChangesToCRDTDoc(
doc,
{ title: '' } as PostChanges,
mockPostType
);
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( '' );
// Now sync "Auto Draft" — should still be suppressed.
applyPostChangesToCRDTDoc(
doc,
{ title: 'Auto Draft' } as PostChanges,
mockPostType
);
expect( map.get( 'title' )?.toString() ).toBe( '' );
} );
it( 'handles excerpt with RenderedText format', () => {
const changes = {
excerpt: {
protected: false,
raw: 'Raw excerpt',
rendered: 'Rendered excerpt',
},
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const excerpt = map.get( 'excerpt' );
expect( excerpt ).toBeInstanceOf( Y.Text );
expect( excerpt?.toString() ).toBe( 'Raw excerpt' );
} );
it( 'does not sync empty slug', () => {
const changes = {
slug: '',
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( map.has( 'slug' ) ).toBe( false );
} );
it( 'syncs non-empty slug', () => {
const changes = {
slug: 'my-post-slug',
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( map.get( 'slug' ) ).toBe( 'my-post-slug' );
} );
it( 'merges blocks changes', () => {
map.set( 'blocks', new Y.Array< YBlock >() );
const changes = {
blocks: [
{
name: 'core/paragraph',
attributes: { content: 'Test' },
innerBlocks: [],
},
],
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( ( map.get( 'blocks' ) as YBlocks ).toJSON() ).toEqual(
changes.blocks
);
} );
it( 'initializes blocks as Y.Array when not present', () => {
const changes = {
blocks: [],
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const blocks = map.get( 'blocks' );
expect( blocks ).toBeInstanceOf( Y.Array );
} );
it( 'sets blocks to undefined when blocks value is undefined', () => {
// First, set some blocks.
map.set( 'blocks', new Y.Array< YBlock >() );
const changes = {
blocks: undefined,
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
// The key should still exist, but the value should be undefined.
expect( map.has( 'blocks' ) ).toBe( true );
expect( map.get( 'blocks' ) ).toBeUndefined();
} );
it( 'syncs content as Y.Text', () => {
const changes = {
content: 'Hello, world!',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const content = map.get( 'content' );
expect( content ).toBeInstanceOf( Y.Text );
expect( content?.toString() ).toBe( 'Hello, world!' );
} );
it( 'syncs content with RenderedText format', () => {
const changes = {
content: {
raw: '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->',
rendered: '<p>Hello</p>',
},
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const content = map.get( 'content' );
expect( content ).toBeInstanceOf( Y.Text );
expect( content?.toString() ).toBe(
'<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->'
);
} );
it( 'updates existing Y.Text title in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ title: 'Old Title' } as PostChanges,
mockPostType
);
const titleRef = map.get( 'title' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ title: 'New Title' } as PostChanges,
mockPostType
);
expect( map.get( 'title' ) ).toBe( titleRef );
expect( map.get( 'title' )?.toString() ).toBe( 'New Title' );
} );
it( 'updates existing Y.Text content in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ content: 'Old content' } as PostChanges,
mockPostType
);
const contentRef = map.get( 'content' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ content: 'New content' } as PostChanges,
mockPostType
);
expect( map.get( 'content' ) ).toBe( contentRef );
expect( map.get( 'content' )?.toString() ).toBe( 'New content' );
} );
it( 'updates existing Y.Text excerpt in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ excerpt: 'Old excerpt' } as PostChanges,
mockPostType
);
const excerptRef = map.get( 'excerpt' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ excerpt: 'New excerpt' } as PostChanges,
mockPostType
);
expect( map.get( 'excerpt' ) ).toBe( excerptRef );
expect( map.get( 'excerpt' )?.toString() ).toBe( 'New excerpt' );
} );
it( 'syncs meta fields', () => {
const changes = {
meta: {
some_meta: 'new value',
},
};
const metaMap = createYMap();
metaMap.set( 'some_meta', 'old value' );
map.set( 'meta', metaMap );
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' );
} );
it( 'syncs non-single meta fields', () => {
const changes = {
meta: {
some_meta: [ 'value', 'value 2' ],
},
};
const metaMap = createYMap();
metaMap.set( 'some_meta', 'old value' );
map.set( 'meta', metaMap );
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
expect( metaMap.get( 'some_meta' ) ).toStrictEqual( [
'value',
'value 2',
] );
} );
it( 'initializes meta as Y.Map when not present', () => {
const changes = {
meta: {
custom_field: 'value',
},
};
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
const metaMap = map.get( 'meta' );
expect( metaMap ).toBeInstanceOf( Y.Map );
expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' );
} );
} );
describe( 'getPostChangesFromCRDTDoc', () => {
const mockPostType = {
slug: 'post',
supports: {
title: true,
editor: true,
},
} as unknown as Type;
let map: YMapWrap< YPostRecord >;
beforeEach( () => {
map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY );
map.set( 'title', new Y.Text( 'CRDT Title' ) );
map.set( 'status', 'draft' );
map.set( 'date', '2025-01-01' );
} );
it( 'returns changes when values differ from record', () => {
const editedRecord = {
title: 'Old Title',
status: 'draft',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.title ).toBe( 'CRDT Title' );
} );
it( 'filters out disallowed properties', () => {
map.set( 'title', new Y.Text( 'Test title' ) );
map.set( 'unsyncedProp', 'value' );
const editedRecord = {} as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes ).not.toHaveProperty( 'unsyncedProp' );
expect( changes.title ).toBe( 'Test title' );
} );
it( 'does not sync auto-draft status', () => {
map.set( 'status', 'auto-draft' );
const editedRecord = {
status: 'draft',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes ).not.toHaveProperty( 'status' );
} );
it( 'does not overwrite null floating date', () => {
map.set( 'status', 'draft' );
map.set( 'date', '' );
const editedRecord = {
status: 'draft',
date: null,
modified: '2025-01-01',
} as unknown as Post;
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
map.set( 'date', '2025-01-02' );
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
} );
it( 'does not overwrite defined floating date', () => {
map.set( 'status', 'draft' );
map.set( 'date', '' );
const editedRecord = {
status: 'draft',
date: '2025-01-01', // matches modified
modified: '2025-01-01',
} as unknown as Post;
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
map.set( 'date', '2025-01-02' );
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
} );
it( 'includes blocks in changes', () => {
map.set( 'blocks', new Y.Array< YBlock >() );
const editedRecord = {
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes ).toHaveProperty( 'blocks' );
} );
it( 'includes undefined blocks in changes', () => {
map.set( 'blocks', undefined );
const editedRecord = {
blocks: [
{
name: 'core/paragraph',
attributes: { content: 'Test' },
innerBlocks: [],
},
],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes ).toHaveProperty( 'blocks' );
expect( changes.blocks ).toBeUndefined();
} );
it( 'detects content changes from string value', () => {
map.set( 'content', new Y.Text( 'New content' ) );
const editedRecord = {
content: 'Old content',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.content ).toBe( 'New content' );
} );
it( 'detects content changes from RenderedText value', () => {
map.set( 'content', new Y.Text( 'New content' ) );
const editedRecord = {
content: { raw: 'Old content', rendered: 'Old content' },
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.content ).toBe( 'New content' );
} );
it( 'excludes content when unchanged from RenderedText value', () => {
map.set( 'content', new Y.Text( 'Same content' ) );
const editedRecord = {
content: { raw: 'Same content', rendered: 'Same content' },
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes ).not.toHaveProperty( 'content' );
} );
it( 'includes meta in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', 'new value' );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'old value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.meta ).toEqual( {
public_meta: 'new value', // from CRDT
} );
} );
it( 'includes non-single meta in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', [ 'value', 'value 2' ] );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.meta ).toEqual( {
public_meta: [ 'value', 'value 2' ], // from CRDT
} );
} );
it( 'excludes disallowed meta keys in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', 'new value' );
metaMap.set(
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
'exclude me'
);
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'old value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
mockPostType
);
expect( changes.meta ).toEqual( {
public_meta: 'new value', // from CRDT
} );
expect( changes.meta ).not.toHaveProperty(
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE
);
} );
} );
} );