diff --git a/tests/js/bindings/block-editor.test.js b/tests/js/bindings/block-editor.test.js new file mode 100644 index 00000000..1aaa0938 --- /dev/null +++ b/tests/js/bindings/block-editor.test.js @@ -0,0 +1,183 @@ +/** + * Unit tests for block-editor.js - filter registration and module behavior + * + * Note: Testing React HOCs that use hooks requires careful mocking. + * These tests focus on verifying module setup and filter registration. + */ + +import { addFilter } from '@wordpress/hooks'; + +// Mock WordPress dependencies +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn(), +} ) ); + +jest.mock( '@wordpress/element', () => ( { + useCallback: jest.fn( ( fn ) => fn ), + useMemo: jest.fn( ( fn ) => fn() ), +} ) ); + +jest.mock( '@wordpress/compose', () => ( { + createHigherOrderComponent: jest.fn( ( fn, name ) => { + const hoc = fn; + hoc.displayName = name; + return hoc; + } ), +} ) ); + +jest.mock( '@wordpress/block-editor', () => ( { + InspectorControls: 'InspectorControls', + useBlockBindingsUtils: jest.fn( () => ( { + updateBlockBindings: jest.fn(), + removeAllBlockBindings: jest.fn(), + } ) ), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + ComboboxControl: 'ComboboxControl', + __experimentalToolsPanel: 'ToolsPanel', + __experimentalToolsPanelItem: 'ToolsPanelItem', +} ) ); + +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +jest.mock( '../../../assets/src/js/bindings/constants', () => ( { + BINDING_SOURCE: 'acf/field', +} ) ); + +jest.mock( '../../../assets/src/js/bindings/utils', () => ( { + getBindableAttributes: jest.fn( () => [] ), + getFilteredFieldOptions: jest.fn( () => [] ), + canUseUnifiedBinding: jest.fn( () => false ), + fieldsToOptions: jest.fn( () => [] ), +} ) ); + +jest.mock( '../../../assets/src/js/bindings/hooks', () => ( { + useSiteEditorContext: jest.fn( () => ( { + isSiteEditor: false, + templatePostType: null, + } ) ), + usePostEditorFields: jest.fn( () => ( {} ) ), + useSiteEditorFields: jest.fn( () => ( { fields: {}, isLoading: false } ) ), + useBoundFields: jest.fn( () => ( { + boundFields: {}, + setBoundFields: jest.fn(), + } ) ), +} ) ); + +describe( 'Block Editor Module', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'Filter Registration', () => { + it( 'should register the editor.BlockEdit filter on module load', () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + expect( addFilter ).toHaveBeenCalledWith( + 'editor.BlockEdit', + 'secure-custom-fields/with-custom-controls', + expect.any( Function ) + ); + } ); + + it( 'should register filter with correct hook name', () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + const [ hookName ] = addFilter.mock.calls[ 0 ]; + expect( hookName ).toBe( 'editor.BlockEdit' ); + } ); + + it( 'should register filter with correct namespace', () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + const [ , namespace ] = addFilter.mock.calls[ 0 ]; + expect( namespace ).toBe( + 'secure-custom-fields/with-custom-controls' + ); + } ); + + it( 'should register filter with a function callback', () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + const [ , , callback ] = addFilter.mock.calls[ 0 ]; + expect( typeof callback ).toBe( 'function' ); + } ); + + it( 'should only register the filter once', () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + expect( addFilter ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'HOC Creation', () => { + it( 'should create a higher order component', () => { + const { + createHigherOrderComponent, + } = require( '@wordpress/compose' ); + + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + expect( createHigherOrderComponent ).toHaveBeenCalledWith( + expect.any( Function ), + 'withCustomControls' + ); + } ); + + it( 'should name the HOC "withCustomControls"', () => { + const { + createHigherOrderComponent, + } = require( '@wordpress/compose' ); + + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + + const [ , hocName ] = createHigherOrderComponent.mock.calls[ 0 ]; + expect( hocName ).toBe( 'withCustomControls' ); + } ); + } ); + + describe( 'Filter Callback', () => { + let filterCallback; + + beforeEach( () => { + jest.isolateModules( () => { + require( '../../../assets/src/js/bindings/block-editor' ); + } ); + filterCallback = addFilter.mock.calls[ 0 ][ 2 ]; + } ); + + it( 'should return a function when passed a BlockEdit component', () => { + const MockBlockEdit = () => null; + const result = filterCallback( MockBlockEdit ); + + expect( typeof result ).toBe( 'function' ); + } ); + + it( 'should wrap different BlockEdit components independently', () => { + const BlockEdit1 = () => 'edit1'; + const BlockEdit2 = () => 'edit2'; + + const wrapped1 = filterCallback( BlockEdit1 ); + const wrapped2 = filterCallback( BlockEdit2 ); + + expect( wrapped1 ).not.toBe( wrapped2 ); + } ); + } ); +} ); diff --git a/tests/js/bindings/constants.test.js b/tests/js/bindings/constants.test.js new file mode 100644 index 00000000..65588896 --- /dev/null +++ b/tests/js/bindings/constants.test.js @@ -0,0 +1,83 @@ +/** + * Unit tests for block binding constants + * + * These tests validate the configuration structure that determines + * which SCF field types can bind to which block attributes. + */ + +import { + BLOCK_BINDINGS_CONFIG, + BINDING_SOURCE, +} from '../../../assets/src/js/bindings/constants'; + +describe( 'Block Binding Constants', () => { + describe( 'BINDING_SOURCE', () => { + it( 'should be acf/field following WordPress binding source convention', () => { + expect( BINDING_SOURCE ).toBe( 'acf/field' ); + expect( BINDING_SOURCE ).toMatch( /^[a-z]+\/[a-z]+$/ ); + } ); + } ); + + describe( 'BLOCK_BINDINGS_CONFIG', () => { + it( 'should define bindings for core blocks', () => { + expect( Object.keys( BLOCK_BINDINGS_CONFIG ) ).toEqual( [ + 'core/paragraph', + 'core/heading', + 'core/image', + 'core/button', + ] ); + } ); + + it( 'should map paragraph content to text-like field types', () => { + expect( BLOCK_BINDINGS_CONFIG[ 'core/paragraph' ].content ).toEqual( + [ 'text', 'textarea', 'date_picker', 'number', 'range' ] + ); + } ); + + it( 'should map heading content same as paragraph', () => { + expect( BLOCK_BINDINGS_CONFIG[ 'core/heading' ].content ).toEqual( + BLOCK_BINDINGS_CONFIG[ 'core/paragraph' ].content + ); + } ); + + it( 'should map all image attributes to image field type only', () => { + const imageConfig = BLOCK_BINDINGS_CONFIG[ 'core/image' ]; + expect( imageConfig ).toEqual( { + id: [ 'image' ], + url: [ 'image' ], + title: [ 'image' ], + alt: [ 'image' ], + } ); + } ); + + it( 'should map button attributes to appropriate field types', () => { + const buttonConfig = BLOCK_BINDINGS_CONFIG[ 'core/button' ]; + expect( buttonConfig.url ).toEqual( [ 'url' ] ); + expect( buttonConfig.text ).toEqual( [ + 'text', + 'checkbox', + 'select', + 'date_picker', + ] ); + expect( buttonConfig.linkTarget ).toEqual( buttonConfig.rel ); + } ); + + it( 'should have valid structure with non-empty field type arrays', () => { + Object.entries( BLOCK_BINDINGS_CONFIG ).forEach( + ( [ blockName, blockConfig ] ) => { + expect( blockName ).toMatch( /^core\// ); + Object.entries( blockConfig ).forEach( + ( [ , fieldTypes ] ) => { + expect( Array.isArray( fieldTypes ) ).toBe( true ); + expect( fieldTypes.length ).toBeGreaterThan( 0 ); + fieldTypes.forEach( ( type ) => { + expect( typeof type ).toBe( 'string' ); + expect( type ).toMatch( /^[a-z_]+$/ ); + } ); + } + ); + } + ); + } ); + } ); +} ); diff --git a/tests/js/bindings/field-processing.test.js b/tests/js/bindings/field-processing.test.js index c6c8f07b..ec30bcd8 100644 --- a/tests/js/bindings/field-processing.test.js +++ b/tests/js/bindings/field-processing.test.js @@ -32,21 +32,14 @@ describe( 'Field Processing Utils', () => { } ); } ); - it( 'should return empty object when post has no acf property', () => { - const post = { id: 1, title: 'Test' }; - const result = getSCFFields( post ); - expect( result ).toEqual( {} ); - } ); - - it( 'should return empty object when post is null', () => { - const result = getSCFFields( null ); - expect( result ).toEqual( {} ); - } ); - - it( 'should return empty object when acf is null', () => { - const post = { acf: null }; - const result = getSCFFields( post ); - expect( result ).toEqual( {} ); + it.each( [ + [ 'null post', null ], + [ 'undefined post', undefined ], + [ 'post without acf', { id: 1 } ], + [ 'post with null acf', { acf: null } ], + [ 'post with empty acf', { acf: {} } ], + ] )( 'should return empty object for %s', ( _desc, post ) => { + expect( getSCFFields( post ) ).toEqual( {} ); } ); it( 'should only include fields with _source counterparts', () => { @@ -54,16 +47,28 @@ describe( 'Field Processing Utils', () => { acf: { field1: 'value1', field1_source: { type: 'text', formatted_value: 'value1' }, - field2: 'value2', - // No field2_source + field2: 'value2', // No _source }, }; - - const result = getSCFFields( post ); - expect( result ).toEqual( { + expect( getSCFFields( post ) ).toEqual( { field1: { type: 'text', formatted_value: 'value1' }, } ); } ); + + it( 'should handle complex image field values', () => { + const post = { + acf: { + img_source: { + type: 'image', + formatted_value: { + id: 123, + url: 'https://example.com/image.jpg', + }, + }, + }, + }; + expect( getSCFFields( post ).img.formatted_value.id ).toBe( 123 ); + } ); } ); describe( 'resolveImageAttribute', () => { @@ -75,235 +80,224 @@ describe( 'Field Processing Utils', () => { title: 'Test Title', }; - it( 'should resolve url attribute', () => { - expect( resolveImageAttribute( imageObj, 'url' ) ).toBe( - 'https://example.com/image.jpg' - ); - } ); - - it( 'should resolve alt attribute', () => { - expect( resolveImageAttribute( imageObj, 'alt' ) ).toBe( - 'Test image' - ); - } ); - - it( 'should resolve title attribute', () => { - expect( resolveImageAttribute( imageObj, 'title' ) ).toBe( - 'Test Title' - ); - } ); - - it( 'should resolve id attribute', () => { - expect( resolveImageAttribute( imageObj, 'id' ) ).toBe( 123 ); + it.each( [ + [ 'url', 'https://example.com/image.jpg' ], + [ 'alt', 'Test image' ], + [ 'title', 'Test Title' ], + [ 'id', 123 ], + ] )( 'should resolve %s attribute', ( attr, expected ) => { + expect( resolveImageAttribute( imageObj, attr ) ).toBe( expected ); } ); it( 'should fallback to ID property for id attribute', () => { - const img = { ID: 456 }; - expect( resolveImageAttribute( img, 'id' ) ).toBe( 456 ); + expect( resolveImageAttribute( { ID: 456 }, 'id' ) ).toBe( 456 ); } ); - it( 'should return empty string for missing attributes', () => { - const img = {}; - expect( resolveImageAttribute( img, 'url' ) ).toBe( '' ); - expect( resolveImageAttribute( img, 'alt' ) ).toBe( '' ); - expect( resolveImageAttribute( img, 'title' ) ).toBe( '' ); + it.each( [ + [ 'missing attribute', imageObj, 'unknown' ], + [ 'null imageObj', null, 'url' ], + [ 'empty imageObj', {}, 'url' ], + ] )( 'should return empty string for %s', ( _desc, img, attr ) => { + expect( resolveImageAttribute( img, attr ) ).toBe( '' ); } ); - it( 'should return empty string for unknown attribute', () => { - expect( resolveImageAttribute( imageObj, 'unknown' ) ).toBe( '' ); - } ); + // Test the || '' fallback branches for each attribute + it.each( [ + [ 'url', { url: '' } ], + [ 'alt', { alt: '' } ], + [ 'title', { title: '' } ], + ] )( + 'should return empty string when %s is empty string', + ( attr, img ) => { + expect( resolveImageAttribute( img, attr ) ).toBe( '' ); + } + ); - it( 'should return empty string for null imageObj', () => { - expect( resolveImageAttribute( null, 'url' ) ).toBe( '' ); + it( 'should return empty string when both id and ID are missing', () => { + expect( resolveImageAttribute( { url: 'test' }, 'id' ) ).toBe( '' ); } ); } ); describe( 'processFieldBinding', () => { const scfFields = { - text_field: { - type: 'text', - formatted_value: 'Hello World', + text_field: { type: 'text', formatted_value: 'Hello World' }, + textarea_field: { + type: 'textarea', + formatted_value: 'Long content', }, image_field: { type: 'image', formatted_value: { url: 'https://example.com/image.jpg', - alt: 'Test image', - title: 'Test Title', + alt: 'Alt text', + title: 'Title', id: 123, }, }, checkbox_field: { type: 'checkbox', - formatted_value: [ 'option1', 'option2' ], - }, - number_field: { - type: 'number', - formatted_value: 42, - }, - textarea_field: { - type: 'textarea', - formatted_value: 'Long text content', + formatted_value: [ 'opt1', 'opt2' ], }, + number_field: { type: 'number', formatted_value: 42 }, }; - it( 'should process text field', () => { - const result = processFieldBinding( - 'content', - { key: 'text_field' }, - scfFields - ); - expect( result ).toBe( 'Hello World' ); - } ); - - it( 'should process image field url attribute', () => { - const result = processFieldBinding( - 'url', - { key: 'image_field' }, - scfFields - ); - expect( result ).toBe( 'https://example.com/image.jpg' ); - } ); - - it( 'should process image field alt attribute', () => { - const result = processFieldBinding( - 'alt', - { key: 'image_field' }, - scfFields - ); - expect( result ).toBe( 'Test image' ); - } ); - - it( 'should process checkbox field as joined string', () => { - const result = processFieldBinding( - 'content', - { key: 'checkbox_field' }, - scfFields - ); - expect( result ).toBe( 'option1, option2' ); - } ); - - it( 'should process number field as string', () => { - const result = processFieldBinding( - 'content', - { key: 'number_field' }, - scfFields - ); - expect( result ).toBe( '42' ); - } ); - - it( 'should return empty string for missing field', () => { - const result = processFieldBinding( - 'content', - { key: 'nonexistent_field' }, - scfFields + // Test all simple field types that return formatted_value directly + it.each( [ + [ 'text', 'text_field', 'Hello World' ], + [ 'textarea', 'textarea_field', 'Long content' ], + [ 'date_picker', 'date_field', '2024-01-15' ], + [ 'url', 'url_field', 'https://example.com' ], + [ 'email', 'email_field', 'test@example.com' ], + [ 'select', 'select_field', 'option_one' ], + [ 'unknown custom type', 'custom_field', 'custom value' ], + ] )( 'should process %s field', ( type, key, value ) => { + const fields = { + [ key ]: { + type: type === 'unknown custom type' ? 'custom' : type, + formatted_value: value, + }, + }; + expect( processFieldBinding( 'content', { key }, fields ) ).toBe( + value ); - expect( result ).toBe( '' ); - } ); - - it( 'should return empty string when args is null', () => { - const result = processFieldBinding( 'content', null, scfFields ); - expect( result ).toBe( '' ); } ); - it( 'should return empty string when key is missing', () => { - const result = processFieldBinding( 'content', {}, scfFields ); - expect( result ).toBe( '' ); + // Test numeric fields that convert to string + it.each( [ + [ 'number', 42, '42' ], + [ 'range', 75, '75' ], + ] )( 'should convert %s field to string', ( type, value, expected ) => { + const fields = { field: { type, formatted_value: value } }; + expect( + processFieldBinding( 'content', { key: 'field' }, fields ) + ).toBe( expected ); } ); - it( 'should handle empty field value', () => { - const fields = { - empty_field: { - type: 'text', - formatted_value: '', - }, - }; - const result = processFieldBinding( - 'content', - { key: 'empty_field' }, - fields - ); - expect( result ).toBe( '' ); + it( 'should process checkbox array as joined string', () => { + expect( + processFieldBinding( + 'content', + { key: 'checkbox_field' }, + scfFields + ) + ).toBe( 'opt1, opt2' ); } ); - it( 'should handle checkbox with string value', () => { + it( 'should process checkbox with string value', () => { const fields = { - checkbox_string: { - type: 'checkbox', - formatted_value: 'single-value', - }, + cb: { type: 'checkbox', formatted_value: 'single' }, }; - const result = processFieldBinding( - 'content', - { key: 'checkbox_string' }, - fields - ); - expect( result ).toBe( 'single-value' ); + expect( + processFieldBinding( 'content', { key: 'cb' }, fields ) + ).toBe( 'single' ); + } ); + + // Test image attribute resolution + it.each( [ + [ 'url', 'https://example.com/image.jpg' ], + [ 'alt', 'Alt text' ], + [ 'title', 'Title' ], + [ 'id', 123 ], + ] )( 'should resolve image %s attribute', ( attr, expected ) => { + expect( + processFieldBinding( attr, { key: 'image_field' }, scfFields ) + ).toBe( expected ); + } ); + + // Test empty/missing returns + it.each( [ + [ 'missing field', { key: 'nonexistent' }, scfFields ], + [ 'null args', null, scfFields ], + [ 'missing key in args', {}, scfFields ], + [ + 'null formatted_value', + { key: 'f' }, + { f: { type: 'text', formatted_value: null } }, + ], + [ + 'undefined formatted_value', + { key: 'f' }, + { f: { type: 'text', formatted_value: undefined } }, + ], + [ + 'empty checkbox array', + { key: 'f' }, + { f: { type: 'checkbox', formatted_value: [] } }, + ], + [ + 'false checkbox', + { key: 'f' }, + { f: { type: 'checkbox', formatted_value: false } }, + ], + [ + 'zero number', + { key: 'f' }, + { f: { type: 'number', formatted_value: 0 } }, + ], + [ + 'zero range', + { key: 'f' }, + { f: { type: 'range', formatted_value: 0 } }, + ], + ] )( 'should return empty string for %s', ( _desc, args, fields ) => { + expect( processFieldBinding( 'content', args, fields ) ).toBe( '' ); } ); } ); describe( 'formatFieldLabel', () => { - it( 'should format field key with underscores', () => { - expect( formatFieldLabel( 'my_field_name' ) ).toBe( - 'My Field Name' - ); - } ); - - it( 'should format single word', () => { - expect( formatFieldLabel( 'field' ) ).toBe( 'Field' ); - } ); - - it( 'should handle already capitalized words', () => { - expect( formatFieldLabel( 'My_Field' ) ).toBe( 'My Field' ); - } ); - - it( 'should return empty string for empty input', () => { - expect( formatFieldLabel( '' ) ).toBe( '' ); - } ); - - it( 'should return empty string for null input', () => { - expect( formatFieldLabel( null ) ).toBe( '' ); - } ); - - it( 'should handle multiple consecutive underscores', () => { - expect( formatFieldLabel( 'my__field' ) ).toBe( 'My Field' ); - } ); + it.each( [ + [ 'my_field_name', 'My Field Name' ], + [ 'field', 'Field' ], + [ 'My_Field', 'My Field' ], + [ 'my__field', 'My Field' ], + ] )( 'should format "%s" to "%s"', ( input, expected ) => { + expect( formatFieldLabel( input ) ).toBe( expected ); + } ); + + it.each( [ '', null ] )( + 'should return empty string for %s', + ( input ) => { + expect( formatFieldLabel( input ) ).toBe( '' ); + } + ); } ); describe( 'getFieldLabel', () => { - const fieldMetadata = { - field1: { label: 'Custom Label 1', type: 'text' }, - field2: { label: 'Custom Label 2', type: 'textarea' }, + const metadata = { + field1: { label: 'Custom Label', type: 'text' }, + field_no_label: { type: 'text' }, // Field exists but has no label property }; - it( 'should return label from metadata', () => { - const result = getFieldLabel( 'field1', fieldMetadata ); - expect( result ).toBe( 'Custom Label 1' ); - } ); - - it( 'should format field key when metadata not provided', () => { - const result = getFieldLabel( 'my_field', null ); - expect( result ).toBe( 'My Field' ); - } ); - - it( 'should format field key when field not in metadata', () => { - const result = getFieldLabel( 'unknown_field', fieldMetadata ); - expect( result ).toBe( 'Unknown Field' ); - } ); - - it( 'should return default label when field key is empty', () => { - const result = getFieldLabel( '', fieldMetadata, 'Default' ); - expect( result ).toBe( 'Default' ); + it( 'should return label from metadata when available', () => { + expect( getFieldLabel( 'field1', metadata ) ).toBe( + 'Custom Label' + ); } ); - it( 'should return default label when field key is null', () => { - const result = getFieldLabel( null, fieldMetadata, 'Default' ); - expect( result ).toBe( 'Default' ); + it( 'should format key when field exists in metadata but has no label', () => { + // Tests the ?.label optional chaining branch + expect( getFieldLabel( 'field_no_label', metadata ) ).toBe( + 'Field No Label' + ); } ); - it( 'should format field key when no default provided', () => { - const result = getFieldLabel( 'my_field' ); - expect( result ).toBe( 'My Field' ); + it.each( [ + [ 'no metadata', 'my_field', null, undefined, 'My Field' ], + [ 'field not in metadata', 'other', metadata, undefined, 'Other' ], + [ 'empty key with default', '', metadata, 'Default', 'Default' ], + [ 'null key with default', null, metadata, 'Default', 'Default' ], + ] )( + 'should handle %s', + ( _desc, key, meta, defaultLabel, expected ) => { + expect( getFieldLabel( key, meta, defaultLabel ) ).toBe( + expected + ); + } + ); + + it( 'should use default parameters when called with only fieldKey', () => { + // Tests the default parameter branches (fieldMetadata = null, defaultLabel = '') + expect( getFieldLabel( 'my_field' ) ).toBe( 'My Field' ); } ); } ); } ); diff --git a/tests/js/bindings/hooks.test.js b/tests/js/bindings/hooks.test.js index 18271709..ffe75e52 100644 --- a/tests/js/bindings/hooks.test.js +++ b/tests/js/bindings/hooks.test.js @@ -363,6 +363,111 @@ describe( 'Custom Hooks', () => { } ); } ); } ); + + it( 'should handle empty scf_field_groups', async () => { + apiFetch.mockResolvedValue( { + scf_field_groups: [], + } ); + + const { result } = renderHook( () => + useSiteEditorFields( 'product' ) + ); + + await waitFor( () => { + expect( result.current.isLoading ).toBe( false ); + } ); + + expect( result.current.fields ).toEqual( {} ); + expect( result.current.error ).toBeNull(); + } ); + + it( 'should handle missing scf_field_groups in response', async () => { + apiFetch.mockResolvedValue( {} ); + + const { result } = renderHook( () => + useSiteEditorFields( 'product' ) + ); + + await waitFor( () => { + expect( result.current.isLoading ).toBe( false ); + } ); + + expect( result.current.fields ).toEqual( {} ); + } ); + + it( 'should cancel fetch on unmount', async () => { + // Use a promise that we can control + let resolvePromise; + const fetchPromise = new Promise( ( resolve ) => { + resolvePromise = resolve; + } ); + apiFetch.mockReturnValue( fetchPromise ); + + const { unmount } = renderHook( () => + useSiteEditorFields( 'product' ) + ); + + // Unmount before the fetch resolves + unmount(); + + // Resolve the fetch after unmount + resolvePromise( { scf_field_groups: mockFieldGroups } ); + + // Wait a tick to ensure any state updates would have happened + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + // If cleanup worked correctly, no state update errors should occur + // Verify test completed by checking unmount didn't throw + expect( true ).toBe( true ); + } ); + + it( 'should handle post type changing from value to null', async () => { + apiFetch.mockResolvedValue( { + scf_field_groups: mockFieldGroups, + } ); + + const { result, rerender } = renderHook( + ( { postType } ) => useSiteEditorFields( postType ), + { initialProps: { postType: 'product' } } + ); + + await waitFor( () => { + expect( result.current.isLoading ).toBe( false ); + } ); + + expect( result.current.fields ).toEqual( { + field1: { label: 'Field 1', type: 'text' }, + field2: { label: 'Field 2', type: 'textarea' }, + } ); + + // Change to null + rerender( { postType: null } ); + + expect( result.current.fields ).toEqual( {} ); + expect( result.current.isLoading ).toBe( false ); + expect( result.current.error ).toBeNull(); + } ); + + it( 'should store field metadata in cache on successful fetch', async () => { + apiFetch.mockResolvedValue( { + scf_field_groups: mockFieldGroups, + } ); + + const { result } = renderHook( () => + useSiteEditorFields( 'product' ) + ); + + await waitFor( () => { + expect( result.current.isLoading ).toBe( false ); + } ); + + expect( fieldMetadataCache.addFieldMetadata ).toHaveBeenCalledWith( + { + field1: { label: 'Field 1', type: 'text' }, + field2: { label: 'Field 2', type: 'textarea' }, + } + ); + } ); } ); describe( 'useBoundFields', () => { @@ -472,5 +577,271 @@ describe( 'Custom Hooks', () => { url: 'field3', } ); } ); + + it( 'should return empty object when metadata is undefined', () => { + const blockAttributes = {}; + + const { result } = renderHook( () => + useBoundFields( blockAttributes ) + ); + + expect( result.current.boundFields ).toEqual( {} ); + } ); + + it( 'should handle null blockAttributes', () => { + const { result } = renderHook( () => useBoundFields( null ) ); + + expect( result.current.boundFields ).toEqual( {} ); + } ); + + it( 'should handle undefined blockAttributes', () => { + const { result } = renderHook( () => useBoundFields( undefined ) ); + + expect( result.current.boundFields ).toEqual( {} ); + } ); + + it( 'should return setBoundFields function', () => { + const blockAttributes = { + metadata: { + bindings: {}, + }, + }; + + const { result } = renderHook( () => + useBoundFields( blockAttributes ) + ); + + expect( typeof result.current.setBoundFields ).toBe( 'function' ); + } ); + + it( 'should ignore bindings with null args', () => { + const blockAttributes = { + metadata: { + bindings: { + content: { + source: 'acf/field', + args: null, + }, + }, + }, + }; + + const { result } = renderHook( () => + useBoundFields( blockAttributes ) + ); + + expect( result.current.boundFields ).toEqual( {} ); + } ); + + it( 'should clear bound fields when bindings are removed', () => { + const initialAttributes = { + metadata: { + bindings: { + content: { + source: 'acf/field', + args: { key: 'field1' }, + }, + }, + }, + }; + + const { result, rerender } = renderHook( + ( { attrs } ) => useBoundFields( attrs ), + { initialProps: { attrs: initialAttributes } } + ); + + expect( result.current.boundFields ).toEqual( { + content: 'field1', + } ); + + // Remove bindings + rerender( { attrs: { metadata: { bindings: {} } } } ); + + expect( result.current.boundFields ).toEqual( {} ); + } ); + } ); + + describe( 'usePostEditorFields - additional edge cases', () => { + it( 'should return empty object when record has no acf property', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => ( { + title: 'Test Post', + content: 'Content', + } ), + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => usePostEditorFields() ); + + expect( result.current ).toEqual( {} ); + } ); + + it( 'should return empty object when record is null', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => null, + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => usePostEditorFields() ); + + expect( result.current ).toEqual( {} ); + } ); + + it( 'should return empty object when postId is null', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + getCurrentPostId: () => null, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => null, + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => usePostEditorFields() ); + + expect( result.current ).toEqual( {} ); + } ); + + it( 'should handle acf with null values', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => ( { + acf: null, + } ), + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => usePostEditorFields() ); + + expect( result.current ).toEqual( {} ); + } ); + } ); + + describe( 'useSiteEditorContext - additional edge cases', () => { + it( 'should handle wp_template_part post type', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'wp_template_part', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => null, + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => useSiteEditorContext() ); + + // wp_template_part is not considered site editor + expect( result.current.isSiteEditor ).toBe( false ); + } ); + + it( 'should handle template with empty slug', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'wp_template', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => ( { + slug: '', + } ), + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => useSiteEditorContext() ); + + expect( result.current.isSiteEditor ).toBe( true ); + expect( result.current.templatePostType ).toBeNull(); + } ); + + it( 'should handle default single template', () => { + useSelect.mockImplementation( ( callback ) => { + const select = ( store ) => { + if ( store === 'core/editor' ) { + return { + getCurrentPostType: () => 'wp_template', + getCurrentPostId: () => 123, + }; + } + if ( store === 'core' ) { + return { + getEditedEntityRecord: () => ( { + slug: 'single', + } ), + }; + } + return {}; + }; + return callback( select ); + } ); + + const { result } = renderHook( () => useSiteEditorContext() ); + + expect( result.current.isSiteEditor ).toBe( true ); + expect( result.current.templatePostType ).toBe( 'post' ); + } ); } ); } ); diff --git a/tests/js/bindings/sources.test.js b/tests/js/bindings/sources.test.js index 58c837b8..ac7b08db 100644 --- a/tests/js/bindings/sources.test.js +++ b/tests/js/bindings/sources.test.js @@ -3,7 +3,6 @@ */ import { registerBlockBindingsSource } from '@wordpress/blocks'; -import { __ } from '@wordpress/i18n'; import * as fieldMetadataCache from '../../../assets/src/js/bindings/fieldMetadataCache'; // Mock field processing module @@ -67,6 +66,22 @@ describe( 'Block Binding Sources', () => { expect( registeredConfig ).not.toBeNull(); expect( registeredConfig.name ).toBe( 'acf/field' ); } ); + + it( 'should register with a label', () => { + expect( registeredConfig.label ).toBe( 'SCF Fields' ); + } ); + + it( 'should have all required methods', () => { + expect( typeof registeredConfig.getLabel ).toBe( 'function' ); + expect( typeof registeredConfig.getValues ).toBe( 'function' ); + expect( typeof registeredConfig.canUserEditValue ).toBe( + 'function' + ); + } ); + + it( 'should only register once', () => { + expect( registerBlockBindingsSource ).toHaveBeenCalledTimes( 1 ); + } ); } ); describe( 'getLabel', () => { @@ -108,6 +123,35 @@ describe( 'Block Binding Sources', () => { expect( label ).toBe( 'My Field' ); } ); + + it( 'should return default label when args is undefined', () => { + const label = registeredConfig.getLabel( { + args: undefined, + select: jest.fn(), + } ); + + expect( label ).toBe( 'SCF Fields' ); + } ); + + it( 'should handle metadata with empty label', () => { + fieldMetadataCache.getFieldMetadata.mockReturnValue( { + label: '', + type: 'text', + } ); + + const { + formatFieldLabel, + } = require( '../../../assets/src/js/bindings/field-processing' ); + formatFieldLabel.mockReturnValue( 'Fallback Label' ); + + const label = registeredConfig.getLabel( { + args: { key: 'my_field' }, + select: jest.fn(), + } ); + + // Should fallback to formatFieldLabel when label is empty + expect( label ).toBe( 'Fallback Label' ); + } ); } ); describe( 'getValues', () => { @@ -220,5 +264,208 @@ describe( 'Block Binding Sources', () => { content: 'Test Title', } ); } ); + + it( 'should handle empty bindings object', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + }; + } + if ( storeName === 'core' ) { + return { + getEditedEntityRecord: () => ( { acf: {} } ), + }; + } + return {}; + } ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: { postType: 'post', postId: 123 }, + bindings: {}, + } ); + + expect( values ).toEqual( {} ); + } ); + + it( 'should handle missing context in post editor', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + }; + } + if ( storeName === 'core' ) { + return { + getEditedEntityRecord: () => undefined, + }; + } + return {}; + } ); + + const { + processFieldBinding, + } = require( '../../../assets/src/js/bindings/field-processing' ); + processFieldBinding.mockReturnValue( '' ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: {}, + bindings: { + content: { args: { key: 'my_field' } }, + }, + } ); + + expect( values ).toEqual( { + content: '', + } ); + } ); + + it( 'should handle binding without args in site editor', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'wp_template', + }; + } + return {}; + } ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: {}, + bindings: { + content: {}, + }, + } ); + + expect( values ).toEqual( { + content: '', + } ); + } ); + + it( 'should handle binding with null key in site editor', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'wp_template', + }; + } + return {}; + } ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: {}, + bindings: { + content: { args: { key: null } }, + }, + } ); + + expect( values ).toEqual( { + content: '', + } ); + } ); + + it( 'should handle multiple bindings in post editor', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + }; + } + if ( storeName === 'core' ) { + return { + getEditedEntityRecord: () => ( { + acf: { + field1_source: { formatted_value: 'Value 1' }, + field2_source: { formatted_value: 'Value 2' }, + }, + } ), + }; + } + return {}; + } ); + + const { + processFieldBinding, + } = require( '../../../assets/src/js/bindings/field-processing' ); + processFieldBinding + .mockReturnValueOnce( 'Value 1' ) + .mockReturnValueOnce( 'Value 2' ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: { postType: 'post', postId: 123 }, + bindings: { + content: { args: { key: 'field1' } }, + url: { args: { key: 'field2' } }, + }, + } ); + + expect( values ).toEqual( { + content: 'Value 1', + url: 'Value 2', + } ); + } ); + + it( 'should handle binding without args in post editor', () => { + const mockSelect = jest.fn( ( storeName ) => { + if ( storeName === 'core/editor' ) { + return { + getCurrentPostType: () => 'post', + }; + } + if ( storeName === 'core' ) { + return { + getEditedEntityRecord: () => ( { acf: {} } ), + }; + } + return {}; + } ); + + const { + processFieldBinding, + } = require( '../../../assets/src/js/bindings/field-processing' ); + processFieldBinding.mockReturnValue( '' ); + + const values = registeredConfig.getValues( { + select: mockSelect, + context: { postType: 'post', postId: 123 }, + bindings: { + content: {}, + }, + } ); + + expect( values ).toEqual( { + content: '', + } ); + } ); + } ); + + describe( 'canUserEditValue', () => { + it( 'should return false to prevent direct editing', () => { + const result = registeredConfig.canUserEditValue(); + + expect( result ).toBe( false ); + } ); + + it( 'should return false regardless of arguments', () => { + const result = registeredConfig.canUserEditValue( { + context: { postType: 'post', postId: 123 }, + args: { key: 'my_field' }, + } ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false for site editor context', () => { + const result = registeredConfig.canUserEditValue( { + context: { postType: 'wp_template' }, + } ); + + expect( result ).toBe( false ); + } ); } ); } );