diff --git a/assets/src/blocks/godam-player/block.json b/assets/src/blocks/godam-player/block.json index ab187334c..ba2018c9e 100644 --- a/assets/src/blocks/godam-player/block.json +++ b/assets/src/blocks/godam-player/block.json @@ -97,6 +97,10 @@ "type": "boolean", "default": false }, + "excludeFromVideoSitemap": { + "type": "boolean", + "default": false + }, "verticalAlignment": { "type": "string", "default": "center" diff --git a/assets/src/blocks/godam-player/components/VideoSEOModal.js b/assets/src/blocks/godam-player/components/VideoSEOModal.js index 622228a1b..ec4741efc 100644 --- a/assets/src/blocks/godam-player/components/VideoSEOModal.js +++ b/assets/src/blocks/godam-player/components/VideoSEOModal.js @@ -21,7 +21,7 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import './video-seo-modal.scss'; -import { appendTimezoneOffsetToUTC, stripHtmlTags } from '../utils'; +import { appendTimezoneOffsetToUTC, stripHtmlTags, getFileNameFromUrl, isFilenameLikeTitle } from '../utils'; /** * Video SEO Modal component @@ -39,6 +39,8 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri const [ mediaSEOData, setMediaSEOData ] = useState( null ); const [ isLoadingMediaSEO, setIsLoadingMediaSEO ] = useState( false ); const [ seoOverride, setSeoOverride ] = useState( attributes?.seoOverride || false ); + const [ excludeFromSitemap, setExcludeFromSitemap ] = useState( !! attributes?.excludeFromVideoSitemap ); + const [ mediaFileName, setMediaFileName ] = useState( getFileNameFromUrl( attributes?.src || '' ) ); const posterThumbnail = attributes?.poster || ''; /** @@ -53,8 +55,9 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri try { const response = await apiFetch( { path: `/wp/v2/media/${ attributes.id }` } ); + // Google indexes HLS (.m3u8) but not DASH (.mpd), so prefer the HLS stream. const seoFromMedia = { - contentUrl: response.meta?.rtgodam_transcoded_url || response.source_url || '', + contentUrl: response.meta?.rtgodam_hls_transcoded_url || response.source_url || '', headline: response.title?.rendered || '', description: stripHtmlTags( response.description?.rendered || '' ), uploadDate: appendTimezoneOffsetToUTC( response.date_gmt || '' ), @@ -64,6 +67,9 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri }; setMediaSEOData( seoFromMedia ); + if ( response.source_url ) { + setMediaFileName( getFileNameFromUrl( response.source_url ) ); + } return seoFromMedia; } catch ( error ) { // eslint-disable-next-line no-console @@ -82,6 +88,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri // Sync local override state with attributes const currentSeoOverride = attributes?.seoOverride || false; setSeoOverride( currentSeoOverride ); + setExcludeFromSitemap( !! attributes?.excludeFromVideoSitemap ); // If seoOverride is false, fetch and display fresh data from media library if ( ! currentSeoOverride ) { @@ -120,7 +127,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri // Also fetch media SEO for comparison/reset purposes fetchMediaSEOData(); - }, [ attributes.seo, attributes?.seoOverride, attributes?.poster, isOpen, fetchMediaSEOData ] ); // Depend on seo attribute and modal state + }, [ attributes.seo, attributes?.seoOverride, attributes?.excludeFromVideoSitemap, attributes?.poster, isOpen, fetchMediaSEOData ] ); // Depend on seo attribute and modal state const updateField = ( field, value ) => { setVideoData( { ...videoData, [ field ]: value } ); @@ -181,6 +188,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri setVideoData( mediaSEOData ); } setSeoOverride( attributes?.seoOverride || false ); + setExcludeFromSitemap( !! attributes?.excludeFromVideoSitemap ); }; const saveData = () => { @@ -192,6 +200,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri setAttributes( { seo: finalVideoData, seoOverride, + excludeFromVideoSitemap: excludeFromSitemap, } ); closeModal(); }; @@ -203,6 +212,8 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri // Check if fields should be editable (only when override is enabled) const isFieldsDisabled = ! seoOverride; + const isHeadlineFilenameLike = isFilenameLikeTitle( videoData?.headline, mediaFileName ); + return ( updateField( 'contentUrl', value ) } - help={ __( 'URL of the video content can be MOV, MP4, MPD. Example: https://www.example.com/video.mp4', 'godam' ) } + help={ __( 'URL of the video file or HLS (.m3u8) stream that search engines index. Example: https://www.example.com/video.mp4', 'godam' ) } disabled={ true } /> + { isHeadlineFilenameLike && ( + + { seoOverride + ? __( 'This title looks like a file name. Search engines show it as the video title, so use a descriptive title with spaces instead.', 'godam' ) + : __( 'This title looks like a file name. Search engines show it as the video title. Edit the video title in the Media Library, or turn on "Override default SEO" to set a descriptive title for this block.', 'godam' ) + } + + ) } + setExcludeFromSitemap( value ) } + help={ __( 'Leave this video out of the GoDAM video sitemap. The video schema on this page is not affected.', 'godam' ) } + />
+
+ + + ); +}; + +export default SeoSettings; diff --git a/pages/godam/redux/slice/media-settings.js b/pages/godam/redux/slice/media-settings.js index baef9964f..6317000b0 100644 --- a/pages/godam/redux/slice/media-settings.js +++ b/pages/godam/redux/slice/media-settings.js @@ -43,6 +43,9 @@ const initialState = { enable_global_video_ads: false, adTagUrl: '', }, + seo: { + enable_video_sitemap: false, + }, integrations: { woocommerce: { enable: true, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 190a5c6a0..d8bb9d7a7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -150,7 +150,15 @@ function wp_get_upload_dir() { ? (string) $GLOBALS['rtgodam_stub']['uploads_baseurl'] : 'https://example.com/wp-content/uploads'; - return array( 'baseurl' => $baseurl ); + $dirs = array( 'baseurl' => $baseurl ); + + // Only set when a test needs the uploads path on disk (e.g. reading a + // transcript file from uploads in VideoSeoSchemaTest). + if ( isset( $GLOBALS['rtgodam_stub']['uploads_basedir'] ) ) { + $dirs['basedir'] = (string) $GLOBALS['rtgodam_stub']['uploads_basedir']; + } + + return $dirs; } } diff --git a/tests/php/DashboardWidgetTest.php b/tests/php/DashboardWidgetTest.php index 711bbb53a..6fe9d88ee 100644 --- a/tests/php/DashboardWidgetTest.php +++ b/tests/php/DashboardWidgetTest.php @@ -160,15 +160,15 @@ public function test_group_mime_counts() { ), Dashboard_Widget::group_mime_counts( array( - 'image/jpeg' => 5, - 'image/png' => 2, - 'video/mp4' => 2, - 'audio/mpeg' => 1, - 'application/pdf' => 1, - 'application/msword' => 2, + 'image/jpeg' => 5, + 'image/png' => 2, + 'video/mp4' => 2, + 'audio/mpeg' => 1, + 'application/pdf' => 1, + 'application/msword' => 2, 'application/vnd.ms-excel' => 1, - 'application/zip' => 1, - 'text/html' => -4, + 'application/zip' => 1, + 'text/html' => -4, ) ) ); @@ -689,7 +689,15 @@ public function test_pick_connect_prompt() { ); $this->assertSame( 'videos', Dashboard_Widget::pick_connect_prompt( $tie )['type'] ); - $this->assertSame( 'audio', Dashboard_Widget::pick_connect_prompt( array( 'audio' => 10, 'videos' => 3 ) )['type'] ); + $this->assertSame( + 'audio', + Dashboard_Widget::pick_connect_prompt( + array( + 'audio' => 10, + 'videos' => 3, + ) + )['type'] + ); $this->assertSame( array( diff --git a/tests/php/VideoSeoKeyMomentsTest.php b/tests/php/VideoSeoKeyMomentsTest.php new file mode 100644 index 000000000..ddc48d1df --- /dev/null +++ b/tests/php/VideoSeoKeyMomentsTest.php @@ -0,0 +1,434 @@ +?t=#godam-video-`. + * + * @package GoDAM + */ + +namespace RTGODAM\Tests; + +use PHPUnit\Framework\TestCase; +use RTGODAM\Inc\Seo; + +require_once dirname( __DIR__ ) . '/stubs/video-seo-functions.php'; +require_once dirname( __DIR__, 2 ) . '/inc/classes/class-seo.php'; + +/** + * @covers \RTGODAM\Inc\Seo + */ +class VideoSeoKeyMomentsTest extends TestCase { + + const PAGE = 'https://example.test/demo/'; + + /** + * @var Seo + */ + private $seo; + + protected function setUp(): void { + parent::setUp(); + $GLOBALS['rtgodam_stub'] = array(); + $GLOBALS['rtgodam_hooks'] = array(); + $this->seo = ( new \ReflectionClass( Seo::class ) )->newInstanceWithoutConstructor(); + } + + protected function tearDown(): void { + unset( $GLOBALS['rtgodam_stub'] ); + $GLOBALS['rtgodam_hooks'] = array(); + parent::tearDown(); + } + + /** + * Chapters as the video editor stores them in rtgodam_meta. + * + * @return array + */ + private function chapters() { + return array( + array( + 'id' => 'c2', + 'text' => 'On-page basics', + 'startTime' => '60', + ), + array( + 'id' => 'c1', + 'text' => 'Introduction', + 'startTime' => '0', + ), + array( + 'id' => 'c3', + 'text' => 'Technical SEO', + 'startTime' => '300.5', + ), + ); + } + + // ----------------------------------------------------------------------------------- + // chapters_to_clips(). + // ----------------------------------------------------------------------------------- + + public function test_chapters_become_sorted_clips_with_deep_links() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 600 ); + + $this->assertSame( + array( + array( + '@type' => 'Clip', + 'name' => 'Introduction', + 'startOffset' => 0, + 'endOffset' => 60, + 'url' => 'https://example.test/demo/?t=0#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'On-page basics', + 'startOffset' => 60, + 'endOffset' => 300, + 'url' => 'https://example.test/demo/?t=60#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'Technical SEO', + 'startOffset' => 300, + 'endOffset' => 600, + 'url' => 'https://example.test/demo/?t=300#godam-video-42', + ), + ), + $clips + ); + } + + public function test_explicit_end_time_is_used() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '0', + 'endTime' => '25', + ), + array( + 'text' => 'Main', + 'startTime' => '40', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( 25, $clips[0]['endOffset'] ); + $this->assertSame( 100, $clips[1]['endOffset'] ); + } + + public function test_last_clip_has_no_end_when_duration_is_unknown() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 0 ); + + $this->assertArrayNotHasKey( 'endOffset', $clips[2] ); + $this->assertSame( 300, $clips[2]['startOffset'] ); + } + + public function test_end_is_clamped_to_the_video_duration() { + $chapters = array( + array( + 'text' => 'Only', + 'startTime' => '10', + 'endTime' => '999', + ), + ); + + $this->assertSame( 50, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 50 )[0]['endOffset'] ); + } + + public function test_invalid_chapters_are_skipped() { + $chapters = array( + array( + 'text' => '', + 'startTime' => '5', + ), + array( + 'text' => 'No start', + 'startTime' => 'abc', + ), + array( + 'text' => 'After the end', + 'startTime' => '120', + ), + array( + 'text' => 'Valid', + 'startTime' => '10', + ), + array( + 'text' => 'Zero length', + 'startTime' => '20', + 'endTime' => '20', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( array( 'Valid' ), array_column( $clips, 'name' ) ); + } + + public function test_duplicate_start_times_keep_the_first_chapter() { + $chapters = array( + array( + 'text' => 'First', + 'startTime' => '0', + ), + array( + 'text' => 'Duplicate', + 'startTime' => '0.4', + ), + array( + 'text' => 'Second', + 'startTime' => '30', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 60 ); + + $this->assertSame( array( 'First', 'Second' ), array_column( $clips, 'name' ) ); + } + + public function test_duplicate_is_dropped_before_ends_are_worked_out() { + // "Recap" floors to the same second as "Intro" and is dropped. Intro must + // still end at Main's start, not at the dropped row's start (0). + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '0', + ), + array( + 'text' => 'Recap', + 'startTime' => '0.4', + ), + array( + 'text' => 'Main', + 'startTime' => '30', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 120 ); + + $this->assertSame( array( 'Intro', 'Main' ), array_column( $clips, 'name' ) ); + $this->assertSame( array( 0, 30 ), array_column( $clips, 'startOffset' ) ); + $this->assertSame( array( 30, 120 ), array_column( $clips, 'endOffset' ) ); + } + + public function test_neighbouring_clips_never_overlap() { + $chapters = array( + array( + 'text' => 'A', + 'startTime' => '10.2', + 'endTime' => '20.7', + ), + array( + 'text' => 'B', + 'startTime' => '20.5', + ), + array( + 'text' => 'C', + 'startTime' => '40', + 'endTime' => '90', + ), + array( + 'text' => 'D', + 'startTime' => '60', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( array( 10, 20, 40, 60 ), array_column( $clips, 'startOffset' ) ); + // A's end rounds down; C's explicit end is capped at D's start. + $this->assertSame( array( 20, 40, 60, 100 ), array_column( $clips, 'endOffset' ) ); + + $count = count( $clips ); + for ( $i = 1; $i < $count; $i++ ) { + $this->assertLessThanOrEqual( $clips[ $i ]['startOffset'], $clips[ $i - 1 ]['endOffset'] ); + } + } + + public function test_chapter_names_are_stripped_of_html() { + $chapters = array( + array( + 'text' => 'Intro & welcome', + 'startTime' => '0', + ), + ); + + $this->assertSame( 'Intro & welcome', Seo::chapters_to_clips( $chapters, self::PAGE, 1, 10 )[0]['name'] ); + } + + public function test_original_time_is_used_when_start_time_is_missing() { + $chapters = array( + array( + 'text' => 'From original time', + 'originalTime' => '01:30', + ), + ); + + $this->assertSame( 90, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 200 )[0]['startOffset'] ); + } + + public function test_url_uses_ampersand_for_plain_permalinks_and_drops_existing_fragment() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '15', + ), + ); + + $this->assertSame( + 'https://example.test/?p=10&t=15#godam-video-7', + Seo::chapters_to_clips( $chapters, 'https://example.test/?p=10#comments', 7, 60 )[0]['url'] + ); + } + + /** + * @dataProvider time_provider + * + * @param mixed $value Input. + * @param float|null $expected Seconds. + */ + public function test_parse_time_to_seconds( $value, $expected ) { + $this->assertSame( $expected, Seo::parse_time_to_seconds( $value ) ); + } + + /** + * @return array + */ + public function time_provider() { + return array( + 'seconds' => array( '90', 90.0 ), + 'decimal' => array( '12.5', 12.5 ), + 'int' => array( 30, 30.0 ), + 'mm:ss' => array( '01:30', 90.0 ), + 'hh:mm:ss' => array( '1:02:03', 3723.0 ), + 'hh:mm:ss.ms' => array( '00:00:05.250', 5.25 ), + 'empty' => array( '', null ), + 'null' => array( null, null ), + 'garbage' => array( 'soon', null ), + 'array' => array( array( 1 ), null ), + ); + } + + public function test_iso8601_to_seconds() { + $this->assertSame( 3723, Seo::iso8601_to_seconds( 'PT1H2M3S' ) ); + $this->assertSame( 0, Seo::iso8601_to_seconds( 'nope' ) ); + } + + // ----------------------------------------------------------------------------------- + // In the schema. + // ----------------------------------------------------------------------------------- + + /** + * A cached SEO entry for attachment 42. + * + * @return array + */ + private function entry() { + return array( + 'headline' => 'SEO basics', + 'description' => 'Learn SEO.', + 'duration' => 'PT10M', + 'thumbnailUrl' => 'https://cdn.example.test/t.jpg', + 'uploadDate' => '2026-01-15T08:00:00+00:00', + 'attachment_id' => 42, + ); + } + + public function test_schema_has_part_lists_chapters_as_clips() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertCount( 3, $schema['hasPart'] ); + $this->assertSame( 'Clip', $schema['hasPart'][0]['@type'] ); + $this->assertSame( 'https://shop.test/?p=10&t=60#godam-video-42', $schema['hasPart'][1]['url'] ); + // Last clip ends at the video duration (PT10M). + $this->assertSame( 600, $schema['hasPart'][2]['endOffset'] ); + } + + public function test_no_has_part_for_videos_under_30_seconds() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + $entry['duration'] = 'PT29S'; + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $entry, 10 ) ); + + $entry['duration'] = 'PT30S'; + $schema = $this->seo->build_video_object_schema( $entry, 10 ); + + // 30 seconds is enough; the 60s and 300s chapters are past the end. + $this->assertSame( array( 'Introduction' ), array_column( $schema['hasPart'], 'name' ) ); + } + + public function test_has_part_kept_when_duration_is_unknown() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + unset( $entry['duration'] ); + + $this->assertCount( 3, $this->seo->build_video_object_schema( $entry, 10 )['hasPart'] ); + } + + public function test_no_has_part_without_chapters() { + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_a_host_page() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + // Render-time output on an archive / 404: no page to deep-link into. + $schema = $this->seo->build_video_object_schema( $this->entry(), 0 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_an_attachment() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + unset( $entry['attachment_id'] ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $entry, 10 ) ); + } + + public function test_key_moments_can_be_turned_off_with_a_filter() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments_enabled', + function () { + return false; + } + ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + } + + public function test_clips_filter_can_change_the_output() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments', + function ( $clips ) { + return array_slice( $clips, 0, 1 ); + } + ); + + $this->assertCount( 1, $this->seo->build_video_object_schema( $this->entry(), 10 )['hasPart'] ); + } + + public function test_has_part_is_valid_json() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $json = wp_json_encode( $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + $decoded = json_decode( $json, true ); + + $this->assertSame( 0, $decoded['hasPart'][0]['startOffset'] ); + $this->assertIsInt( $decoded['hasPart'][0]['endOffset'] ); + } +} diff --git a/tests/php/VideoSeoSchemaTest.php b/tests/php/VideoSeoSchemaTest.php new file mode 100644 index 000000000..a7637c2a7 --- /dev/null +++ b/tests/php/VideoSeoSchemaTest.php @@ -0,0 +1,688 @@ +seo = ( new \ReflectionClass( Seo::class ) )->newInstanceWithoutConstructor(); + } + + protected function tearDown(): void { + foreach ( $this->temp_dirs as $dir ) { + $this->remove_dir( $dir ); + } + unset( $GLOBALS['rtgodam_stub'] ); + $GLOBALS['rtgodam_hooks'] = array(); + parent::tearDown(); + } + + /** + * A cached SEO entry as Seo::get_seo_from_attachment() used to store it + * by an older version: DASH contentUrl, empty description, no embedUrl. + * + * @param array $overrides Fields to override. + * @return array + */ + private function legacy_entry( array $overrides = array() ) { + return array_merge( + array( + 'contentUrl' => 'https://cdn.example.test/job_1/manifest.mpd', + 'headline' => 'Product demo', + 'description' => '', + 'uploadDate' => '2026-01-15T10:30:00+00:00', + 'duration' => 'PT1M30S', + 'thumbnailUrl' => 'https://cdn.example.test/job_1/thumb.jpg', + 'isFamilyFriendly' => true, + 'attachment_id' => 42, + ), + $overrides + ); + } + + // ----------------------------------------------------------------------------------- + // contentUrl. + // ----------------------------------------------------------------------------------- + + public function test_content_url_uses_hls_stream_over_cached_dash_manifest() { + rtgodam_stub_hls_url( 'https://cdn.example.test/job_1/index.m3u8', array( 42 ) ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertSame( 'https://cdn.example.test/job_1/index.m3u8', $schema['contentUrl'] ); + } + + public function test_content_url_falls_back_to_original_file_without_hls() { + $GLOBALS['rtgodam_stub']['attachment_url'] = 'https://example.test/wp-content/uploads/demo.mp4'; + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertSame( 'https://example.test/wp-content/uploads/demo.mp4', $schema['contentUrl'] ); + } + + public function test_dash_manifest_is_never_output_as_content_url() { + // No attachment to resolve, and the only stored URL is a DASH manifest. + $entry = $this->legacy_entry( array( 'attachment_id' => 0 ) ); + + $schema = $this->seo->build_video_object_schema( $entry, 0 ); + + $this->assertArrayNotHasKey( 'contentUrl', $schema ); + } + + public function test_dash_url_is_ignored_even_when_it_is_the_hls_meta_value() { + rtgodam_stub_hls_url( 'https://cdn.example.test/job_1/manifest.mpd?token=1', array( 42 ) ); + $GLOBALS['rtgodam_stub']['attachment_url'] = 'https://example.test/wp-content/uploads/demo.mp4'; + + $this->assertSame( 'https://example.test/wp-content/uploads/demo.mp4', $this->seo->get_attachment_content_url( 42 ) ); + } + + public function test_non_dash_stored_url_is_kept_for_entries_without_an_attachment() { + $entry = $this->legacy_entry( + array( + 'attachment_id' => 0, + 'contentUrl' => 'https://videos.example.test/clip.mp4', + ) + ); + + $schema = $this->seo->build_video_object_schema( $entry, 0 ); + + $this->assertSame( 'https://videos.example.test/clip.mp4', $schema['contentUrl'] ); + } + + /** + * @dataProvider dash_url_provider + * + * @param string $url URL. + * @param bool $expected Whether it's a DASH manifest. + */ + public function test_is_dash_url( $url, $expected ) { + $this->assertSame( $expected, Seo::is_dash_url( $url ) ); + } + + /** + * @return array + */ + public function dash_url_provider() { + return array( + 'mpd' => array( 'https://cdn.example.test/a/manifest.mpd', true ), + 'mpd upper case' => array( 'https://cdn.example.test/a/MANIFEST.MPD', true ), + 'mpd with query' => array( 'https://cdn.example.test/a/manifest.mpd?x=1', true ), + 'm3u8' => array( 'https://cdn.example.test/a/index.m3u8', false ), + 'mp4' => array( 'https://example.test/video.mp4', false ), + 'mpd only in query' => array( 'https://example.test/video.mp4?f=a.mpd', false ), + 'empty' => array( '', false ), + ); + } + + // ----------------------------------------------------------------------------------- + // embedUrl. + // ----------------------------------------------------------------------------------- + + public function test_embed_url_is_the_godam_player_page() { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertSame( 'https://example.test?godam_page=video-embed&id=42', $schema['embedUrl'] ); + } + + public function test_no_embed_url_without_an_attachment() { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry( array( 'attachment_id' => 0 ) ), 0 ); + + $this->assertArrayNotHasKey( 'embedUrl', $schema ); + } + + // ----------------------------------------------------------------------------------- + // description. + // ----------------------------------------------------------------------------------- + + public function test_own_description_is_used_when_present() { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry( array( 'description' => '

A short tour.

' ) ), 0 ); + + $this->assertSame( 'A short tour.', $schema['description'] ); + } + + public function test_empty_description_is_left_out_not_output_empty() { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertArrayNotHasKey( 'description', $schema ); + $this->assertStringNotContainsString( '"description"', wp_json_encode( $schema ) ); + } + + public function test_description_falls_back_to_cached_transcript_excerpt() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + Seo::TRANSCRIPT_EXCERPT_META_KEY => array( + 'path' => 'https://cdn.example.test/job_1/transcript.vtt', + 'text' => 'Welcome to the product demo.', + ), + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertSame( 'Welcome to the product demo.', $schema['description'] ); + } + + public function test_page_render_never_fetches_an_uncached_transcript() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + ); + + $this->seo->build_video_object_schema( $this->legacy_entry(), 0 ); + + $this->assertEmpty( $GLOBALS['rtgodam_stub']['remote_get_urls'] ?? array() ); + } + + public function test_description_falls_back_to_page_excerpt_after_transcript() { + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => 'Everything you need to know about our product.', + 'post_content' => '', + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + $this->assertSame( 'Everything you need to know about our product.', $schema['description'] ); + } + + public function test_page_content_fallback_strips_blocks_and_shortcodes() { + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => '', + 'post_content' => "

Hello & welcome.

\n[gallery ids=\"1,2\"]", + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + $this->assertSame( 'Hello & welcome.', $schema['description'] ); + } + + public function test_page_text_cannot_close_the_json_ld_script_tag() { + // An Author's paragraph, stored with entities (kses allows this). + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => '', + 'post_password' => '', + 'post_content' => '

Watch </script><img src=x onerror=alert(document.domain)> now

', + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + $this->assertStringNotContainsString( '<', $schema['description'] ); + $this->assertStringNotContainsString( '', $schema['description'] ); + + // And the JSON output flags escape < > & in any value, as a second line of defence. + $json = json_encode( array( 'description' => 'a b & c' ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- tests PHP's own flag behaviour. + $this->assertStringNotContainsString( '', $json ); + $this->assertStringContainsString( '\\u003C/script\\u003E', $json ); + } + + public function test_transcript_text_cannot_contain_tags_after_decoding() { + $vtt = "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHi </script><b>there</b>\n"; + + $this->assertSame( 'Hi there', Seo::transcript_to_text( $vtt ) ); + } + + public function test_password_protected_page_text_is_not_used() { + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => 'Secret members-only notes.', + 'post_password' => 'hunter2', + 'post_content' => 'Secret members-only content.', + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + $this->assertArrayNotHasKey( 'description', $schema ); + } + + /** + * @dataProvider non_public_status_provider + * + * @param string $status Post status. + */ + public function test_non_public_post_text_is_not_used( $status ) { + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'post', + 'post_status' => $status, + 'post_excerpt' => '', + 'post_password' => '', + 'post_content' => 'Internal: do not share.', + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + $this->assertArrayNotHasKey( 'description', $schema ); + } + + /** + * @return array + */ + public function non_public_status_provider() { + return array( + 'private' => array( 'private' ), + 'draft' => array( 'draft' ), + 'pending' => array( 'pending' ), + 'future' => array( 'future' ), + ); + } + + public function test_null_byte_in_transcript_url_does_not_break_the_save() { + $this->make_uploads_dir(); + $GLOBALS['rtgodam_stub']['post_meta'] = array( 'rtgodam_transcript_path' => 'https://example.com/wp-content/uploads/2026/t%00.vtt' ); + + // realpath() would throw a ValueError on the NUL byte. + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, true ) ); + } + + public function test_legacy_entry_without_attachment_queues_one_resave() { + // Elementor / WPBakery cache from an older version: no attachment_id, DASH URL. + $entry = $this->legacy_entry( array( 'attachment_id' => 0 ) ); + + $this->seo->build_video_object_schema( $entry, 10 ); + $this->seo->build_video_object_schema( $entry, 10 ); + $this->seo->flush_legacy_resave_queue(); + + $this->assertSame( array( 10 ), $GLOBALS['rtgodam_stub']['options'][ Seo::LEGACY_RESAVE_QUEUE_OPTION ] ); + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['scheduled'] ); + $this->assertSame( Seo::LEGACY_RESAVE_CRON_HOOK, $GLOBALS['rtgodam_stub']['scheduled'][0]['hook'] ); + } + + public function test_many_legacy_pages_share_one_queue_write_and_one_cron_event() { + for ( $post_id = 1; $post_id <= 30; $post_id++ ) { + // The post meta stub isn't per post: clear the previous page's flag. + unset( $GLOBALS['rtgodam_stub']['post_meta'][ Seo::LEGACY_RESAVE_META_KEY ] ); + $this->seo->build_video_object_schema( $this->legacy_entry( array( 'attachment_id' => 0 ) ), $post_id ); + } + + // Nothing is written until shutdown. + $this->assertArrayNotHasKey( 'scheduled', $GLOBALS['rtgodam_stub'] ); + + $this->seo->flush_legacy_resave_queue(); + $this->seo->flush_legacy_resave_queue(); // A second flush is a no-op. + + $this->assertSame( range( 1, 30 ), $GLOBALS['rtgodam_stub']['options'][ Seo::LEGACY_RESAVE_QUEUE_OPTION ] ); + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['scheduled'] ); + } + + public function test_resave_queue_is_processed_in_batches() { + $seo = new class() extends Seo { + /** + * @var int[] + */ + public $resaved = array(); + + public function __construct() {} + + /** + * @param int $post_id Post ID. + */ + public function resave_post_video_seo( $post_id ) { + $this->resaved[] = $post_id; + } + }; + + $GLOBALS['rtgodam_stub']['options'][ Seo::LEGACY_RESAVE_QUEUE_OPTION ] = range( 1, 25 ); + + $seo->process_legacy_resave_queue(); + + $this->assertSame( range( 1, Seo::LEGACY_RESAVE_BATCH_SIZE ), $seo->resaved ); + $this->assertSame( range( Seo::LEGACY_RESAVE_BATCH_SIZE + 1, 25 ), $GLOBALS['rtgodam_stub']['options'][ Seo::LEGACY_RESAVE_QUEUE_OPTION ] ); + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['scheduled'] ); + + // Work through the rest; the last run schedules nothing new. + unset( $GLOBALS['rtgodam_stub']['scheduled'] ); + $seo->process_legacy_resave_queue(); + unset( $GLOBALS['rtgodam_stub']['scheduled'] ); + $seo->process_legacy_resave_queue(); + + $this->assertSame( range( 1, 25 ), $seo->resaved ); + $this->assertSame( array(), $GLOBALS['rtgodam_stub']['options'][ Seo::LEGACY_RESAVE_QUEUE_OPTION ] ); + $this->assertArrayNotHasKey( 'scheduled', $GLOBALS['rtgodam_stub'] ); + } + + public function test_current_entries_do_not_queue_a_resave() { + $this->seo->build_video_object_schema( $this->legacy_entry(), 10 ); + $this->seo->build_video_object_schema( + $this->legacy_entry( + array( + 'attachment_id' => 0, + 'contentUrl' => 'https://videos.example.test/clip.mp4', + ) + ), + 10 + ); + + $this->seo->flush_legacy_resave_queue(); + + $this->assertArrayNotHasKey( 'scheduled', $GLOBALS['rtgodam_stub'] ); + } + + // ----------------------------------------------------------------------------------- + // isFamilyFriendly and other fields. + // ----------------------------------------------------------------------------------- + + /** + * @dataProvider family_friendly_provider + * + * @param mixed $stored Stored value. + * @param bool $expected Expected boolean. + */ + public function test_is_family_friendly_is_a_json_boolean( $stored, $expected ) { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry( array( 'isFamilyFriendly' => $stored ) ), 0 ); + + $this->assertSame( $expected, $schema['isFamilyFriendly'] ); + $this->assertStringContainsString( '"isFamilyFriendly":' . ( $expected ? 'true' : 'false' ), wp_json_encode( $schema ) ); + } + + /** + * @return array + */ + public function family_friendly_provider() { + return array( + 'bool true' => array( true, true ), + 'bool false' => array( false, false ), + 'string True' => array( 'True', true ), + 'string false' => array( 'false', false ), + 'string 0' => array( '0', false ), + 'string 1' => array( '1', true ), + 'string yes' => array( 'yes', true ), + 'elementor off' => array( '', false ), + 'int 0' => array( 0, false ), + ); + } + + public function test_is_family_friendly_defaults_to_true_when_missing() { + $entry = $this->legacy_entry(); + unset( $entry['isFamilyFriendly'] ); + + $schema = $this->seo->build_video_object_schema( $entry, 0 ); + + $this->assertTrue( $schema['isFamilyFriendly'] ); + } + + public function test_empty_upload_date_is_left_out() { + $schema = $this->seo->build_video_object_schema( $this->legacy_entry( array( 'uploadDate' => '' ) ), 0 ); + + $this->assertArrayNotHasKey( 'uploadDate', $schema ); + } + + public function test_full_schema_shape() { + rtgodam_stub_hls_url( 'https://cdn.example.test/job_1/index.m3u8', array( 42 ) ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry( array( 'description' => 'A tour.' ) ), 0 ); + + $this->assertSame( + array( + '@context' => 'https://schema.org', + '@type' => 'VideoObject', + 'name' => 'Product demo', + 'description' => 'A tour.', + 'contentUrl' => 'https://cdn.example.test/job_1/index.m3u8', + 'embedUrl' => 'https://example.test?godam_page=video-embed&id=42', + 'uploadDate' => '2026-01-15T10:30:00+00:00', + 'isFamilyFriendly' => true, + 'thumbnailUrl' => 'https://cdn.example.test/job_1/thumb.jpg', + 'duration' => 'PT1M30S', + ), + $schema + ); + } + + public function test_entry_without_headline_produces_no_schema() { + $this->assertNull( $this->seo->build_video_object_schema( $this->legacy_entry( array( 'headline' => '' ) ), 0 ) ); + } + + // ----------------------------------------------------------------------------------- + // Transcript excerpt. + // ----------------------------------------------------------------------------------- + + public function test_transcript_is_fetched_once_on_save_and_cached() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + ); + $GLOBALS['rtgodam_stub']['http'] = array( + 'code' => 200, + 'body' => "WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000\nHello and welcome.\n\n2\n00:00:02.000 --> 00:00:04.000\nLet's get started.\n", + ); + + $this->assertSame( 'Hello and welcome. Let\'s get started.', $this->seo->get_transcript_excerpt( 42, true ) ); + $this->assertSame( + array( + 'path' => 'https://cdn.example.test/job_1/transcript.vtt', + 'text' => 'Hello and welcome. Let\'s get started.', + ), + $GLOBALS['rtgodam_stub']['meta_writes'][ Seo::TRANSCRIPT_EXCERPT_META_KEY ] + ); + + // Second call is served from the cache: no new request. + $this->seo->get_transcript_excerpt( 42, true ); + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['remote_get_urls'] ); + } + + public function test_failed_transcript_fetch_is_remembered_for_an_hour() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + ); + $GLOBALS['rtgodam_stub']['http'] = array( + 'code' => 500, + 'body' => '', + ); + + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, true ) ); + $this->assertSame( '', $GLOBALS['rtgodam_stub']['meta_writes'][ Seo::TRANSCRIPT_EXCERPT_META_KEY ]['text'] ); + + // A save right after doesn't wait on the dead URL again. + $this->seo->get_transcript_excerpt( 42, true ); + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['remote_get_urls'] ); + } + + public function test_failed_transcript_fetch_is_retried_after_an_hour() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + Seo::TRANSCRIPT_EXCERPT_META_KEY => array( + 'path' => 'https://cdn.example.test/job_1/transcript.vtt', + 'text' => '', + 'failed_at' => time() - HOUR_IN_SECONDS - 1, + ), + ); + $GLOBALS['rtgodam_stub']['http'] = array( + 'code' => 200, + 'body' => "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nWorks now.\n", + ); + + $this->assertSame( 'Works now.', $this->seo->get_transcript_excerpt( 42, true ) ); + + // A page render (no remote fetches) still just reads the cache. + $GLOBALS['rtgodam_stub']['post_meta'][ Seo::TRANSCRIPT_EXCERPT_META_KEY ]['failed_at'] = time() - HOUR_IN_SECONDS - 1; + $GLOBALS['rtgodam_stub']['post_meta'][ Seo::TRANSCRIPT_EXCERPT_META_KEY ]['text'] = ''; + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, false ) ); + } + + public function test_deleted_transcript_is_not_used() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/transcript.vtt', + 'rtgodam_transcript_deleted' => '1', + ); + + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, true ) ); + } + + public function test_stale_cache_for_an_older_transcript_is_ignored() { + $GLOBALS['rtgodam_stub']['post_meta'] = array( + 'rtgodam_transcript_path' => 'https://cdn.example.test/job_1/new.vtt', + Seo::TRANSCRIPT_EXCERPT_META_KEY => array( + 'path' => 'https://cdn.example.test/job_1/old.vtt', + 'text' => 'Old text.', + ), + ); + + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, false ) ); + } + + public function test_transcript_to_text_handles_webvtt() { + $vtt = "\xEF\xBB\xBFWEBVTT - Demo\nKind: captions\n\nNOTE This is a comment\n\nSTYLE\n::cue { color: red }\n\nintro\n00:00:00.000 --> 00:00:02.500 align:start\nHi there, & welcome!\n\n00:00:02.500 --> 00:00:05.000\nThis is line one\nand line two.\n"; + + // "intro" is a WebVTT cue identifier, not spoken text. + $this->assertSame( 'Hi there, & welcome! This is line one and line two.', Seo::transcript_to_text( $vtt ) ); + } + + public function test_transcript_keeps_cue_text_that_looks_like_a_header_or_a_number() { + $vtt = "WEBVTT\n\nchapter-1\n00:00:00.000 --> 00:00:02.000\nNote this down.\n\n2\n00:00:02.000 --> 00:00:04.000\n2024\nRegion four results.\n"; + + $this->assertSame( 'Note this down. 2024 Region four results.', Seo::transcript_to_text( $vtt ) ); + } + + public function test_transcript_skips_note_style_and_region_blocks() { + $vtt = "WEBVTT\n\nNOTE\nThis is a comment\nover two lines\n\nREGION\nid:fred width:40%\n\nSTYLE\n::cue { color: red }\n\n00:00:00.000 --> 00:00:02.000\nOnly this.\n"; + + $this->assertSame( 'Only this.', Seo::transcript_to_text( $vtt ) ); + } + + public function test_transcript_in_uploads_is_read_from_disk() { + $base = $this->make_uploads_dir(); + file_put_contents( $base . '/uploads/2026/t.vtt', "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nFrom disk.\n" ); + + $GLOBALS['rtgodam_stub']['post_meta'] = array( 'rtgodam_transcript_path' => 'https://example.com/wp-content/uploads/2026/t.vtt' ); + + $this->assertSame( 'From disk.', $this->seo->get_transcript_excerpt( 42, true ) ); + $this->assertEmpty( $GLOBALS['rtgodam_stub']['remote_get_urls'] ?? array() ); + } + + public function test_transcript_path_escaping_the_uploads_dir_is_not_read_from_disk() { + $base = $this->make_uploads_dir(); + // A sibling directory whose name starts with "uploads". + mkdir( $base . '/uploads-private' ); + file_put_contents( $base . '/uploads-private/secret.vtt', "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSecret.\n" ); + + $GLOBALS['rtgodam_stub']['post_meta'] = array( 'rtgodam_transcript_path' => 'https://example.com/wp-content/uploads/../uploads-private/secret.vtt' ); + + $this->assertSame( '', $this->seo->get_transcript_excerpt( 42, true ) ); + // It fell through to the (stubbed, 404) remote fetch instead of reading the file. + $this->assertCount( 1, $GLOBALS['rtgodam_stub']['remote_get_urls'] ); + } + + /** + * Recursively remove a temporary directory. + * + * @param string $dir Directory. + * @return void + */ + private function remove_dir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + foreach ( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ) as $item ) { + $item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- test cleanup. + } + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- test cleanup. + } + + /** + * Create a temporary wp-content with an uploads dir and point the stubs at it. + * + * @return string wp-content path. + */ + private function make_uploads_dir() { + $base = sys_get_temp_dir() . '/godam-seo-test-' . uniqid(); + mkdir( $base . '/uploads/2026', 0777, true ); + $this->temp_dirs[] = $base; + + $GLOBALS['rtgodam_stub']['uploads_baseurl'] = 'https://example.com/wp-content/uploads'; + $GLOBALS['rtgodam_stub']['uploads_basedir'] = $base . '/uploads'; + + return $base; + } + + public function test_transcript_to_text_handles_srt() { + $srt = "1\r\n00:00:00,000 --> 00:00:02,000\r\nFirst cue.\r\n\r\n2\r\n00:00:02,000 --> 00:00:04,000\r\nSecond cue.\r\n"; + + $this->assertSame( 'First cue. Second cue.', Seo::transcript_to_text( $srt ) ); + } + + public function test_trim_to_length_keeps_short_text() { + $this->assertSame( 'Short text.', Seo::trim_to_length( " Short \n text. ", 160 ) ); + } + + public function test_trim_to_length_cuts_at_a_word_boundary_within_the_limit() { + $text = str_repeat( 'word ', 60 ); // 300 characters. + $result = Seo::trim_to_length( $text, Seo::DESCRIPTION_MAX_LENGTH ); + + $this->assertLessThanOrEqual( 160, mb_strlen( $result ) ); + $this->assertStringEndsWith( 'word…', $result ); + } + + public function test_trim_to_length_is_multibyte_safe() { + $result = Seo::trim_to_length( str_repeat( 'é', 200 ), 160 ); + + $this->assertSame( 160, mb_strlen( $result ) ); + $this->assertTrue( mb_check_encoding( $result, 'UTF-8' ) ); + } + + /** + * @dataProvider to_bool_provider + * + * @param mixed $value Value. + * @param bool $expected Expected. + */ + public function test_to_bool( $value, $expected ) { + $this->assertSame( $expected, Seo::to_bool( $value ) ); + } + + /** + * @return array + */ + public function to_bool_provider() { + return array( + array( 'True', true ), + array( 'FALSE', false ), + array( ' no ', false ), + array( 'off', false ), + array( 'on', true ), + array( null, false ), + array( 1, true ), + ); + } +} diff --git a/tests/php/VideoSeoWpbakeryTest.php b/tests/php/VideoSeoWpbakeryTest.php new file mode 100644 index 000000000..cf56cf7d7 --- /dev/null +++ b/tests/php/VideoSeoWpbakeryTest.php @@ -0,0 +1,133 @@ +seo = ( new \ReflectionClass( Wpbakery_Seo_Double::class ) )->newInstanceWithoutConstructor(); + $this->seo->media = array( + 7 => array( + 'headline' => 'Media Library title', + 'description' => 'Media Library description.', + 'contentUrl' => 'https://cdn.example.test/7/master.m3u8', + 'uploadDate' => '2026-01-15T08:00:00+00:00', + 'thumbnailUrl' => 'https://cdn.example.test/7/t.jpg', + 'duration' => 'PT2M', + 'isFamilyFriendly' => true, + ), + ); + } + + protected function tearDown(): void { + unset( $GLOBALS['rtgodam_stub'] ); + parent::tearDown(); + } + + /** + * Attributes an element saves with Override = No after this PR: the editor + * fills every SEO param with the Media Library values. + * + * @param int $id Attachment ID. + * @return string + */ + private function synced_shortcode( $id ) { + return '[godam_video id="' . $id . '" seo_override="0" seo_headline="Old title" seo_description="Old description."' + . ' seo_content_url="https://cdn.example.test/old/master.m3u8" seo_upload_date="2025-01-01T00:00:00+00:00"' + . ' seo_duration="PT1M" seo_thumbnail_url="https://cdn.example.test/old.jpg" seo_family_friendly="1"]'; + } + + public function test_override_off_uses_the_media_library() { + $schemas = $this->seo->godam_get_video_seo_data_from_wpbakery( $this->synced_shortcode( 7 ) ); + + $this->assertCount( 1, $schemas ); + $this->assertSame( 'Media Library title', $schemas[0]['headline'] ); + $this->assertSame( 'https://cdn.example.test/7/master.m3u8', $schemas[0]['contentUrl'] ); + $this->assertSame( 7, $schemas[0]['attachment_id'] ); + } + + public function test_override_off_with_a_deleted_video_outputs_nothing() { + $result = $this->seo->godam_get_video_seo_data_from_wpbakery( $this->synced_shortcode( 99 ), true ); + + $this->assertSame( array(), $result['schemas'] ); + $this->assertSame( array(), $result['attachments'] ); + } + + public function test_override_off_with_an_empty_media_title_outputs_nothing() { + $this->seo->media[7]['headline'] = ''; + + $this->assertSame( array(), $this->seo->godam_get_video_seo_data_from_wpbakery( $this->synced_shortcode( 7 ) ) ); + } + + public function test_override_off_with_an_empty_media_title_still_tracks_the_video() { + // So filling in the title later re-syncs the page. + $this->seo->media[7]['headline'] = ''; + + $result = $this->seo->godam_get_video_seo_data_from_wpbakery( '[godam_video id="7" seo_override="0"]', true ); + + $this->assertSame( array(), $result['schemas'] ); + $this->assertSame( array( 7 ), $result['attachments'] ); + } + + public function test_override_on_uses_the_custom_values_and_media_technical_values() { + $content = '[godam_video id="7" seo_override="1" seo_headline="Custom title" seo_description="Custom description."' + . ' seo_content_url="https://cdn.example.test/old/master.m3u8" seo_family_friendly="0"]'; + + $schemas = $this->seo->godam_get_video_seo_data_from_wpbakery( $content ); + + $this->assertSame( 'Custom title', $schemas[0]['headline'] ); + $this->assertSame( 'Custom description.', $schemas[0]['description'] ); + $this->assertFalse( $schemas[0]['isFamilyFriendly'] ); + // Technical values still come from the attachment. + $this->assertSame( 'https://cdn.example.test/7/master.m3u8', $schemas[0]['contentUrl'] ); + $this->assertSame( 'PT2M', $schemas[0]['duration'] ); + } + + public function test_override_on_with_a_deleted_video_falls_back_to_stored_values() { + $content = '[godam_video id="99" seo_override="1" seo_headline="Custom title" seo_content_url="https://cdn.example.test/x.m3u8"]'; + + $schemas = $this->seo->godam_get_video_seo_data_from_wpbakery( $content ); + + $this->assertSame( 'Custom title', $schemas[0]['headline'] ); + $this->assertSame( 'https://cdn.example.test/x.m3u8', $schemas[0]['contentUrl'] ); + } + + public function test_wpbakery_encoded_characters_are_decoded() { + $content = '[godam_video id="7" seo_override="1" seo_headline="Demo `{`2026`}` ``Final``"' + . ' seo_description="Part `{`1`}` of ``the`` series."]'; + + $schemas = $this->seo->godam_get_video_seo_data_from_wpbakery( $content ); + + $this->assertSame( 'Demo [2026] "Final"', $schemas[0]['headline'] ); + $this->assertSame( 'Part [1] of "the" series.', $schemas[0]['description'] ); + } + + public function test_decode_wpbakery_param() { + $this->assertSame( '[a] "b"', Seo::decode_wpbakery_param( '`{`a`}` ``b``' ) ); + $this->assertSame( 'plain', Seo::decode_wpbakery_param( 'plain' ) ); + $this->assertSame( '', Seo::decode_wpbakery_param( array( 'x' ) ) ); + } +} diff --git a/tests/php/VideoSitemapTest.php b/tests/php/VideoSitemapTest.php new file mode 100644 index 000000000..23435d37e --- /dev/null +++ b/tests/php/VideoSitemapTest.php @@ -0,0 +1,671 @@ + list of cached SEO entries. + * @return Video_Sitemap + */ + private function sitemap_with_pages( array $pages ) { + return new class( $pages ) extends Video_Sitemap { + + /** + * @var array + */ + private $pages; + + /** + * @param array $pages Fixtures. + */ + public function __construct( array $pages ) { // phpcs:ignore -- no parent constructor: it would register hooks. + $this->pages = $pages; + } + + /** + * @param int $page Page. + * @return int[] + */ + protected function query_post_ids( $page ) { + return array_keys( $this->pages ); + } + + /** + * @param int $post_id Post ID. + * @return array + */ + protected function get_cached_videos( $post_id ) { + return $this->pages[ $post_id ]; + } + + /** + * @param int $post_id Post ID. + * @return string + */ + protected function get_post_url( $post_id ) { + return 'https://example.test/?p=' . $post_id; + } + + /** + * @param int $post_id Post ID. + * @return string + */ + protected function get_post_lastmod( $post_id ) { + return '2026-10-01T08:00:00+00:00'; + } + }; + } + + /** + * A cached SEO entry as Seo saves it. + * + * @param int $attachment_id Attachment ID. + * @param array $overrides Fields to override. + * @return array + */ + private function video( $attachment_id, array $overrides = array() ) { + return array_merge( + array( + 'contentUrl' => 'https://cdn.example.test/job_' . $attachment_id . '/manifest.mpd', + 'headline' => 'Video ' . $attachment_id, + 'description' => 'About video ' . $attachment_id . '.', + 'uploadDate' => '2026-01-15T10:30:00+00:00', + 'duration' => 'PT2M5S', + 'thumbnailUrl' => 'https://cdn.example.test/job_' . $attachment_id . '/thumb.jpg', + 'isFamilyFriendly' => true, + 'attachment_id' => $attachment_id, + ), + $overrides + ); + } + + /** + * Parse XML and fail on any well-formedness error. + * + * @param string $xml XML. + * @return \DOMXPath + */ + private function parse( $xml ) { + $doc = new \DOMDocument(); + $this->assertTrue( $doc->loadXML( $xml ), 'Sitemap XML must be well-formed.' ); + + $xpath = new \DOMXPath( $doc ); + $xpath->registerNamespace( 's', self::SITEMAP_NS ); + $xpath->registerNamespace( 'video', self::VIDEO_NS ); + + return $xpath; + } + + // ----------------------------------------------------------------------------------- + // XML output. + // ----------------------------------------------------------------------------------- + + public function test_urlset_xml_is_well_formed_with_one_url_per_page_and_one_video_per_video() { + rtgodam_stub_hls_url( 'https://cdn.example.test/index.m3u8', array( 1, 2, 3 ) ); + + $sitemap = $this->sitemap_with_pages( + array( + 10 => array( $this->video( 1 ), $this->video( 2 ) ), + 11 => array( $this->video( 3 ) ), + ) + ); + + $xpath = $this->parse( Video_Sitemap::build_urlset_xml( $sitemap->get_url_entries( 1 ) ) ); + + $this->assertSame( 2, $xpath->query( '/s:urlset/s:url' )->length ); + $this->assertSame( 2, $xpath->query( '/s:urlset/s:url[1]/video:video' )->length ); + $this->assertSame( 1, $xpath->query( '/s:urlset/s:url[2]/video:video' )->length ); + $this->assertSame( 'https://example.test/?p=10', $xpath->evaluate( 'string(/s:urlset/s:url[1]/s:loc)' ) ); + $this->assertSame( '2026-10-01T08:00:00+00:00', $xpath->evaluate( 'string(/s:urlset/s:url[1]/s:lastmod)' ) ); + } + + public function test_video_element_carries_every_field_from_the_schema() { + rtgodam_stub_hls_url( 'https://cdn.example.test/job_1/index.m3u8', array( 1, 2, 3 ) ); + + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1 ) ) ) ); + $xpath = $this->parse( Video_Sitemap::build_urlset_xml( $sitemap->get_url_entries( 1 ) ) ); + $base = '/s:urlset/s:url[1]/video:video[1]/'; + + $this->assertSame( 'https://cdn.example.test/job_1/thumb.jpg', $xpath->evaluate( "string({$base}video:thumbnail_loc)" ) ); + $this->assertSame( 'Video 1', $xpath->evaluate( "string({$base}video:title)" ) ); + $this->assertSame( 'About video 1.', $xpath->evaluate( "string({$base}video:description)" ) ); + // HLS, the same URL the schema uses — never the cached .mpd. + $this->assertSame( 'https://cdn.example.test/job_1/index.m3u8', $xpath->evaluate( "string({$base}video:content_loc)" ) ); + $this->assertSame( 'https://example.test?godam_page=video-embed&id=1', $xpath->evaluate( "string({$base}video:player_loc)" ) ); + $this->assertSame( '125', $xpath->evaluate( "string({$base}video:duration)" ) ); + $this->assertSame( '2026-01-15T10:30:00+00:00', $xpath->evaluate( "string({$base}video:publication_date)" ) ); + $this->assertSame( 0, $xpath->query( "{$base}video:family_friendly" )->length ); + } + + public function test_sitemap_and_schema_agree() { + rtgodam_stub_hls_url( 'https://cdn.example.test/job_1/index.m3u8', array( 1, 2, 3 ) ); + $entry = $this->video( 1 ); + + $schema = Seo::get_instance()->build_video_object_schema( $entry, 10 ); + $sitemap = $this->sitemap_with_pages( array( 10 => array( $entry ) ) ); + $video = $sitemap->get_url_entries( 1 )[0]['videos'][0]; + + $this->assertSame( $schema['contentUrl'], $video['content_loc'] ); + $this->assertSame( $schema['embedUrl'], $video['player_loc'] ); + $this->assertSame( $schema['name'], $video['title'] ); + $this->assertSame( $schema['description'], $video['description'] ); + $this->assertSame( $schema['thumbnailUrl'], $video['thumbnail_loc'] ); + } + + public function test_values_are_xml_escaped() { + $xml = Video_Sitemap::build_urlset_xml( + array( + array( + 'loc' => 'https://example.test/?p=1&lang=en', + 'videos' => array( + array( + 'thumbnail_loc' => 'https://cdn.example.test/t.jpg?a=1&b=2', + 'title' => 'Tips & "tricks" ', + 'description' => "Control char \x07 removed. Ünïcödé kept.", + 'player_loc' => 'https://example.test/?godam_page=video-embed&id=1', + ), + ), + ), + ) + ); + + $xpath = $this->parse( $xml ); + + $this->assertSame( 'https://example.test/?p=1&lang=en', $xpath->evaluate( 'string(//s:loc)' ) ); + $this->assertSame( 'Tips & "tricks" ', $xpath->evaluate( 'string(//video:title)' ) ); + $this->assertSame( 'Control char removed. Ünïcödé kept.', $xpath->evaluate( 'string(//video:description)' ) ); + } + + public function test_family_friendly_no_is_output_when_not_family_friendly() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'isFamilyFriendly' => 'false' ) ) ) ) ); + $xpath = $this->parse( Video_Sitemap::build_urlset_xml( $sitemap->get_url_entries( 1 ) ) ); + + $this->assertSame( 'no', $xpath->evaluate( 'string(//video:family_friendly)' ) ); + } + + public function test_duration_outside_googles_range_is_left_out() { + $xml = Video_Sitemap::build_urlset_xml( + array( + array( + 'loc' => 'https://example.test/?p=1', + 'videos' => array( + array( + 'thumbnail_loc' => 'https://cdn.example.test/t.jpg', + 'title' => 'Long stream', + 'description' => 'Nine hours.', + 'content_loc' => 'https://cdn.example.test/index.m3u8', + 'duration' => 9 * 3600, + ), + ), + ), + ) + ); + + $this->assertSame( 0, $this->parse( $xml )->query( '//video:duration' )->length ); + } + + public function test_empty_sitemap_is_still_valid_xml() { + $xpath = $this->parse( Video_Sitemap::build_urlset_xml( array() ) ); + + $this->assertSame( 1, $xpath->query( '/s:urlset' )->length ); + $this->assertSame( 0, $xpath->query( '//s:url' )->length ); + } + + public function test_description_falls_back_to_title_because_google_requires_one() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'description' => '' ) ) ) ) ); + $video = $sitemap->get_url_entries( 1 )[0]['videos'][0]; + + $this->assertSame( 'Video 1', $video['description'] ); + } + + public function test_missing_thumbnail_falls_back_to_featured_image() { + $GLOBALS['rtgodam_stub']['featured_image'][10] = 'https://example.test/featured.jpg'; + + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'thumbnailUrl' => '' ) ) ) ) ); + $video = $sitemap->get_url_entries( 1 )[0]['videos'][0]; + + $this->assertSame( 'https://example.test/featured.jpg', $video['thumbnail_loc'] ); + } + + public function test_video_without_thumbnail_is_dropped_and_empty_page_is_not_listed() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'thumbnailUrl' => '' ) ) ) ) ); + + $this->assertSame( array(), $sitemap->get_url_entries( 1 ) ); + } + + public function test_same_video_twice_on_a_page_is_listed_once() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1 ), $this->video( 1 ) ) ) ); + + $this->assertCount( 1, $sitemap->get_url_entries( 1 )[0]['videos'] ); + } + + public function test_long_titles_are_cut_to_googles_100_character_limit() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'headline' => str_repeat( 'Long title ', 20 ) ) ) ) ) ); + $title = $sitemap->get_url_entries( 1 )[0]['videos'][0]['title']; + + $this->assertLessThanOrEqual( 100, mb_strlen( $title ) ); + $this->assertStringEndsWith( '…', $title ); + } + + public function test_index_skips_pages_that_end_up_empty_and_reports_real_counts() { + // Page 1: one listable page. Page 2: only a noindexed page. + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['posts'][20] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['yoast_meta'][20]['meta-robots-noindex'] = '1'; + + $sitemap = new class( array( + 1 => array( 10 => array( $this->video( 1 ) ) ), + 2 => array( 20 => array( $this->video( 2 ) ) ), + ) ) extends Video_Sitemap { + + /** + * @var array + */ + private $pages_by_number; + + /** + * @param array $pages_by_number Page number => post ID => entries. + */ + public function __construct( array $pages_by_number ) { // phpcs:ignore -- no parent constructor: it would register hooks. + $this->pages_by_number = $pages_by_number; + } + + /** + * @return int + */ + public function get_max_pages() { + return 2; + } + + /** + * @return string + */ + public function get_last_modified() { + return '2026-10-01T08:00:00+00:00'; + } + + /** + * @param int $page Page. + * @return int[] + */ + protected function query_post_ids( $page ) { + return array_keys( $this->pages_by_number[ $page ] ); + } + + /** + * @param int $post_id Post ID. + * @return array + */ + protected function get_cached_videos( $post_id ) { + foreach ( $this->pages_by_number as $posts ) { + if ( isset( $posts[ $post_id ] ) ) { + return $posts[ $post_id ]; + } + } + return array(); + } + + /** + * @param int $post_id Post ID. + * @return string + */ + protected function get_post_url( $post_id ) { + return 'https://example.test/?p=' . $post_id; + } + + /** + * @param int $post_id Post ID. + * @return string + */ + protected function get_post_lastmod( $post_id ) { + return ''; + } + }; + + $GLOBALS['rtgodam_stub']['options']['permalink_structure'] = ''; + $items = $sitemap->get_index_items(); + + $this->assertCount( 1, $items ); + $this->assertSame( 'https://example.test?godam_video_sitemap=1', $items[0]['loc'] ); + $this->assertSame( 1, $items[0]['count'] ); + + // The WordPress core sitemap index (no SEO plugin) lists the same pages. + require_once dirname( __DIR__, 2 ) . '/inc/classes/class-video-sitemap-core-provider.php'; + $provider = new \RTGODAM\Inc\Video_Sitemap_Core_Provider( $sitemap ); + + $this->assertSame( + array( array( 'loc' => 'https://example.test?godam_video_sitemap=1' ) ), + $provider->get_sitemap_entries() + ); + } + + // ----------------------------------------------------------------------------------- + // Cache invalidation. + // ----------------------------------------------------------------------------------- + + /** + * Current cache version. + * + * @return int + */ + private function cache_version() { + return (int) ( $GLOBALS['rtgodam_stub']['options'][ Video_Sitemap::CACHE_VERSION_OPTION ] ?? 1 ); + } + + public function test_saving_a_post_without_videos_does_not_invalidate() { + $sitemap = $this->sitemap_with_pages( array() ); + + $sitemap->maybe_invalidate_on_cleared( 99, false ); + $this->assertSame( 1, $this->cache_version() ); + + // A post that had videos and no longer does: invalidated. + $sitemap->maybe_invalidate_on_cleared( 10, true ); + $this->assertSame( 2, $this->cache_version() ); + } + + public function test_enabled_filter_added_after_load_is_respected() { + $sitemap = $this->sitemap_with_pages( array() ); + + // Off: nothing served or listed. + $sitemap->register_sitemap_hooks(); + $this->assertArrayNotHasKey( 'parse_request', $GLOBALS['rtgodam_hooks'] ); + + // A theme's functions.php turns it on (runs before after_setup_theme). + add_filter( + 'godam_video_sitemap_enabled', + function () { + return true; + } + ); + $sitemap->register_sitemap_hooks(); + + $this->assertArrayHasKey( 'parse_request', $GLOBALS['rtgodam_hooks'] ); + $this->assertArrayHasKey( 'robots_txt', $GLOBALS['rtgodam_hooks'] ); + } + + public function test_robots_txt_lists_the_sitemap_on_public_sites_only() { + $sitemap = $this->sitemap_with_pages( array() ); + $GLOBALS['rtgodam_stub']['options']['permalink_structure'] = ''; + + $this->assertSame( + "User-agent: *\nDisallow:\nSitemap: https://example.test?godam_video_sitemap=1\n", + $sitemap->add_robots_txt_sitemap( "User-agent: *\nDisallow:\n", true ) + ); + $this->assertSame( 'User-agent: *', $sitemap->add_robots_txt_sitemap( 'User-agent: *', false ) ); + } + + public function test_settings_save_invalidates_only_when_seo_settings_change() { + $sitemap = $this->sitemap_with_pages( array() ); + + $sitemap->maybe_invalidate_on_settings_update( + array( + 'seo' => array( 'enable_video_sitemap' => true ), + 'video_player' => array( 'brand_color' => '#000' ), + ), + array( + 'seo' => array( 'enable_video_sitemap' => true ), + 'video_player' => array( 'brand_color' => '#fff' ), + ) + ); + $this->assertSame( 1, $this->cache_version() ); + + $sitemap->maybe_invalidate_on_settings_update( + array( 'seo' => array( 'enable_video_sitemap' => false ) ), + array( 'seo' => array( 'enable_video_sitemap' => true ) ) + ); + $this->assertSame( 2, $this->cache_version() ); + } + + public function test_attachment_edit_invalidates_only_for_videos_in_use() { + $sitemap = $this->sitemap_with_pages( array() ); + + // An image edit (e.g. alt text): no invalidation. + $GLOBALS['rtgodam_stub']['mime'] = 'image/jpeg'; + $GLOBALS['rtgodam_stub']['meta_exists'][5][ Seo::ATTACHMENT_POSTS_MAP_META_KEY ] = true; + $sitemap->maybe_invalidate_on_attachment_edit( 5 ); + $this->assertSame( 1, $this->cache_version() ); + + // A video no page uses: no invalidation. + $GLOBALS['rtgodam_stub']['mime'] = 'video/mp4'; + $sitemap->maybe_invalidate_on_attachment_edit( 6 ); + $this->assertSame( 1, $this->cache_version() ); + + // A video a listed page uses: invalidated. + $sitemap->maybe_invalidate_on_attachment_edit( 5 ); + $this->assertSame( 2, $this->cache_version() ); + } + + public function test_hls_url_update_on_a_used_video_invalidates() { + $sitemap = $this->sitemap_with_pages( array() ); + $GLOBALS['rtgodam_stub']['meta_exists'][5][ Seo::ATTACHMENT_POSTS_MAP_META_KEY ] = true; + + // Unrelated meta on a used video: no invalidation. + $sitemap->maybe_invalidate_on_attachment_meta( 1, 5, '_wp_attachment_image_alt' ); + $this->assertSame( 1, $this->cache_version() ); + + // HLS URL on a video no page uses: no invalidation. + $sitemap->maybe_invalidate_on_attachment_meta( 1, 6, 'rtgodam_hls_transcoded_url' ); + $this->assertSame( 1, $this->cache_version() ); + + // HLS URL written by the transcoding callback on a used video: invalidated. + $sitemap->maybe_invalidate_on_attachment_meta( 1, 5, 'rtgodam_hls_transcoded_url' ); + $this->assertSame( 2, $this->cache_version() ); + } + + // ----------------------------------------------------------------------------------- + // Exclusion rules. + // ----------------------------------------------------------------------------------- + + public function test_query_only_lists_published_unprotected_pages_with_videos() { + $args = $this->sitemap_with_pages( array() )->get_base_query_args(); + + // Drafts, pending, scheduled and private posts are all excluded by status. + $this->assertSame( 'publish', $args['post_status'] ); + $this->assertFalse( $args['has_password'] ); + $this->assertSame( Seo::VIDEO_SEO_SCHEMA_META_KEY, $args['meta_key'] ); + $this->assertNotContains( 'attachment', $args['post_type'] ); + } + + public function test_video_excluded_by_the_editor_is_left_out() { + $sitemap = $this->sitemap_with_pages( + array( + 10 => array( $this->video( 1, array( 'excludeFromSitemap' => true ) ), $this->video( 2 ) ), + ) + ); + + $videos = $sitemap->get_url_entries( 1 )[0]['videos']; + + $this->assertCount( 1, $videos ); + $this->assertSame( 'Video 2', $videos[0]['title'] ); + } + + public function test_page_whose_only_video_is_excluded_is_left_out() { + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'excludeFromSitemap' => true ) ) ) ) ); + + $this->assertSame( array(), $sitemap->get_url_entries( 1 ) ); + } + + public function test_page_noindexed_in_yoast_is_left_out() { + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['posts'][11] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['yoast_meta'][10]['meta-robots-noindex'] = '1'; + + $sitemap = $this->sitemap_with_pages( + array( + 10 => array( $this->video( 1 ) ), + 11 => array( $this->video( 2 ) ), + ) + ); + + $entries = $sitemap->get_url_entries( 1 ); + + $this->assertCount( 1, $entries ); + $this->assertSame( 'https://example.test/?p=11', $entries[0]['loc'] ); + } + + public function test_yoast_post_type_noindex_applies_unless_the_page_overrides_it() { + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['posts'][11] = (object) array( 'post_type' => 'page' ); + $GLOBALS['rtgodam_stub']['yoast_options']['noindex-page'] = true; + $GLOBALS['rtgodam_stub']['yoast_meta'][11]['meta-robots-noindex'] = '2'; // Explicit "index". + + $sitemap = $this->sitemap_with_pages( array() ); + + $this->assertTrue( $sitemap->is_post_noindex( 10 ) ); + $this->assertFalse( $sitemap->is_post_noindex( 11 ) ); + } + + public function test_page_noindexed_in_rank_math_is_left_out() { + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( 'post_type' => 'post' ); + $GLOBALS['rtgodam_stub']['rank_math_noindex'] = array( 10 ); + + $this->assertTrue( $this->sitemap_with_pages( array() )->is_post_noindex( 10 ) ); + } + + public function test_indexable_page_is_kept() { + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( 'post_type' => 'post' ); + + $this->assertFalse( $this->sitemap_with_pages( array() )->is_post_excluded( 10 ) ); + } + + public function test_exclusion_filter_can_drop_a_page() { + add_filter( + 'godam_video_sitemap_exclude_post', + function ( $excluded, $post_id ) { + return 11 === $post_id ? true : $excluded; + }, + 10, + 2 + ); + + $sitemap = $this->sitemap_with_pages( + array( + 10 => array( $this->video( 1 ) ), + 11 => array( $this->video( 2 ) ), + ) + ); + + $this->assertCount( 1, $sitemap->get_url_entries( 1 ) ); + } + + // ----------------------------------------------------------------------------------- + // Routing, settings and helpers. + // ----------------------------------------------------------------------------------- + + /** + * @dataProvider path_provider + * + * @param string $path Request path. + * @param int|null $expected Page number. + */ + public function test_parse_page_from_path( $path, $expected ) { + $this->assertSame( $expected, Video_Sitemap::parse_page_from_path( $path ) ); + } + + /** + * @return array + */ + public function path_provider() { + return array( + 'page 1' => array( 'godam-video-sitemap.xml', 1 ), + 'page 3' => array( 'godam-video-sitemap3.xml', 3 ), + 'leading slash' => array( '/godam-video-sitemap2.xml', 2 ), + 'page 0 is page 1' => array( 'godam-video-sitemap0.xml', 1 ), + 'other sitemap' => array( 'post-sitemap.xml', null ), + 'subdirectory' => array( 'blog/godam-video-sitemap.xml', null ), + 'suffix' => array( 'godam-video-sitemap.xml.gz', null ), + 'empty' => array( '', null ), + ); + } + + public function test_requested_page_from_plain_permalink_query_var() { + $wp = new \stdClass(); + $wp->query_vars = array( Video_Sitemap::QUERY_VAR => '2' ); + $wp->request = ''; + + $this->assertSame( 2, Video_Sitemap::get_requested_page( $wp ) ); + } + + public function test_plain_permalink_sites_get_a_query_string_url() { + $GLOBALS['rtgodam_stub']['options']['permalink_structure'] = ''; + + $this->assertSame( 'https://example.test?godam_video_sitemap=2', Video_Sitemap::get_sitemap_url( 2 ) ); + } + + public function test_sitemap_is_off_by_default() { + $this->assertFalse( Video_Sitemap::is_enabled() ); + + $GLOBALS['rtgodam_stub']['options']['rtgodam-settings'] = array( 'seo' => array( 'enable_video_sitemap' => true ) ); + $this->assertTrue( Video_Sitemap::is_enabled() ); + } + + /** + * @dataProvider duration_provider + * + * @param string $iso ISO 8601 duration. + * @param int $expected Seconds. + */ + public function test_iso8601_duration_to_seconds( $iso, $expected ) { + $this->assertSame( $expected, Video_Sitemap::iso8601_duration_to_seconds( $iso ) ); + } + + /** + * @return array + */ + public function duration_provider() { + return array( + array( 'PT1H2M3S', 3723 ), + array( 'PT45S', 45 ), + array( 'PT2M', 120 ), + array( 'PT1.6S', 2 ), + array( 'P1DT1S', 86401 ), + array( 'pt10m', 600 ), + array( 'garbage', 0 ), + array( '', 0 ), + ); + } + + public function test_format_gmt_date() { + $this->assertSame( '2026-10-13T09:30:00+00:00', Video_Sitemap::format_gmt_date( '2026-10-13 09:30:00' ) ); + $this->assertSame( '', Video_Sitemap::format_gmt_date( '0000-00-00 00:00:00' ) ); + } +} diff --git a/tests/stubs/class-wpbakery-seo-double.php b/tests/stubs/class-wpbakery-seo-double.php new file mode 100644 index 000000000..3eb5f02a2 --- /dev/null +++ b/tests/stubs/class-wpbakery-seo-double.php @@ -0,0 +1,32 @@ + + */ + public $media = array(); + + /** + * @param int $attachment_id Attachment ID. + * @param bool $allow_remote Ignored. + * @return array + */ + public function get_seo_from_attachment( $attachment_id, $allow_remote = true ) { + return $this->media[ $attachment_id ] ?? array(); + } +} diff --git a/tests/stubs/shortcode-functions.php b/tests/stubs/shortcode-functions.php new file mode 100644 index 000000000..00451fa5c --- /dev/null +++ b/tests/stubs/shortcode-functions.php @@ -0,0 +1,87 @@ + $attachment_id, + 'post_type' => 'attachment', + ) + ); + } + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_hls_transcoded_url'] = $url; + } + } + + if ( ! function_exists( 'esc_url_raw' ) ) { + + /** + * @param string $url URL. + * @return string + */ + function esc_url_raw( $url ) { + return (string) $url; + } + } + + if ( ! class_exists( 'WP_Post' ) ) { + + /** + * Minimal WP_Post, so code that checks `instanceof WP_Post` (e.g. + * rtgodam_get_hls_transcoded_url_from_attachment()) accepts fixtures. + */ + #[\AllowDynamicProperties] + class WP_Post { + // phpcs:ignore Generic.Classes.DuplicateClassName.Found -- test stub. + + /** + * @param array $props Properties. + */ + public function __construct( array $props = array() ) { + foreach ( $props as $key => $value ) { + $this->$key = $value; + } + } + } + } + + if ( ! function_exists( 'get_post' ) ) { + + /** + * @param int $post_id Post ID. + * @return object|null Post from $GLOBALS['rtgodam_stub']['posts'][ $post_id ]. + */ + function get_post( $post_id = 0 ) { + return $GLOBALS['rtgodam_stub']['posts'][ (int) $post_id ] ?? null; + } + } + + if ( ! function_exists( 'is_post_publicly_viewable' ) ) { + + /** + * Published posts are viewable; a post stub without a status counts as published. + * + * @param WP_Post|object $post Post. + * @return bool + */ + function is_post_publicly_viewable( $post = null ) { + return is_object( $post ) && 'publish' === ( $post->post_status ?? 'publish' ); + } + } + + if ( ! function_exists( 'get_post_type' ) ) { + + /** + * @param int $post_id Post ID. + * @return string|false + */ + function get_post_type( $post_id = 0 ) { + $post = get_post( $post_id ); + return $post ? $post->post_type : false; + } + } + + if ( ! function_exists( 'has_excerpt' ) ) { + + /** + * @param object $post Post. + * @return bool + */ + function has_excerpt( $post = 0 ) { + return is_object( $post ) && '' !== (string) $post->post_excerpt; + } + } + + if ( ! function_exists( 'strip_shortcodes' ) ) { + + /** + * @param string $content Content. + * @return string + */ + function strip_shortcodes( $content ) { + return preg_replace( '/\[[^\]]*\]/', '', (string) $content ); + } + } + + if ( ! function_exists( 'trailingslashit' ) ) { + + /** + * @param string $value Path or URL. + * @return string + */ + function trailingslashit( $value ) { + return rtrim( (string) $value, '/\\' ) . '/'; + } + } + + if ( ! function_exists( 'wp_safe_remote_get' ) ) { + + /** + * Records the URL and returns $GLOBALS['rtgodam_stub']['http']. + * + * @param string $url URL. + * @param array $args Ignored. + * @return array + */ + function wp_safe_remote_get( $url, $args = array() ) { + $GLOBALS['rtgodam_stub']['remote_get_urls'][] = $url; + return $GLOBALS['rtgodam_stub']['http'] ?? array( + 'code' => 404, + 'body' => '', + ); + } + } + + if ( ! function_exists( 'update_post_meta' ) ) { + + /** + * Records the write into $GLOBALS['rtgodam_stub']['post_meta']. + * + * @param int $post_id Post ID. + * @param string $key Meta key. + * @param mixed $value Value. + * @return bool + */ + function update_post_meta( $post_id, $key, $value ) { + $GLOBALS['rtgodam_stub']['post_meta'][ $key ] = $value; + $GLOBALS['rtgodam_stub']['meta_writes'][ $key ] = $value; + return true; + } + } + + if ( ! function_exists( 'get_permalink' ) ) { + + /** + * Same shape as tests/stubs/product-lookup-functions.php's get_permalink() + * (default "shop" site), so results match whichever stub loads first. + * + * @param int $post Post ID. + * @return string + */ + function get_permalink( $post = 0 ) { + return 'https://shop.test/?p=' . (int) $post; + } + } + + if ( ! function_exists( 'update_option' ) ) { + + /** + * Writes into $GLOBALS['rtgodam_stub']['options'], which the get_option() stub reads. + * + * @param string $option Option name. + * @param mixed $value Value. + * @param mixed $autoload Ignored. + * @return bool + */ + function update_option( $option, $value, $autoload = null ) { + $GLOBALS['rtgodam_stub']['options'][ $option ] = $value; + return true; + } + } + + if ( ! function_exists( 'wp_next_scheduled' ) ) { + + /** + * @param string $hook Hook. + * @param array $args Args. + * @return int|false + */ + function wp_next_scheduled( $hook, $args = array() ) { + foreach ( $GLOBALS['rtgodam_stub']['scheduled'] ?? array() as $event ) { + if ( $event['hook'] === $hook && $event['args'] === $args ) { + return $event['timestamp']; + } + } + return false; + } + } + + if ( ! function_exists( 'wp_schedule_single_event' ) ) { + + /** + * Records the event in $GLOBALS['rtgodam_stub']['scheduled']. + * + * @param int $timestamp Timestamp. + * @param string $hook Hook. + * @param array $args Args. + * @return bool + */ + function wp_schedule_single_event( $timestamp, $hook, $args = array() ) { + $GLOBALS['rtgodam_stub']['scheduled'][] = array( + 'timestamp' => $timestamp, + 'hook' => $hook, + 'args' => $args, + ); + return true; + } + } + + if ( ! function_exists( 'did_action' ) ) { + + /** + * @param string $hook Hook. + * @return int + */ + function did_action( $hook ) { + return (int) ( $GLOBALS['rtgodam_stub']['did_action'][ $hook ] ?? 0 ); + } + } + + if ( ! function_exists( 'metadata_exists' ) ) { + + /** + * True when $GLOBALS['rtgodam_stub']['meta_exists'][ $object_id ][ $meta_key ] is set. + * + * @param string $meta_type Ignored. + * @param int $object_id Object ID. + * @param string $meta_key Meta key. + * @return bool + */ + function metadata_exists( $meta_type, $object_id, $meta_key ) { + return ! empty( $GLOBALS['rtgodam_stub']['meta_exists'][ (int) $object_id ][ $meta_key ] ); + } + } + + if ( ! function_exists( 'get_the_post_thumbnail_url' ) ) { + + /** + * @param int $post_id Post ID. + * @param string $size Ignored. + * @return string|false + */ + function get_the_post_thumbnail_url( $post_id = 0, $size = 'post-thumbnail' ) { + return $GLOBALS['rtgodam_stub']['featured_image'][ (int) $post_id ] ?? false; + } + } + + if ( ! function_exists( 'get_post_types' ) ) { + + /** + * @param array $args Ignored. + * @return string[] + */ + function get_post_types( $args = array() ) { + return $GLOBALS['rtgodam_stub']['post_types'] ?? array( + 'post' => 'post', + 'page' => 'page', + 'attachment' => 'attachment', + ); + } + } + + if ( ! class_exists( 'WP_Sitemaps_Provider' ) ) { + + /** + * Minimal core sitemap provider base, for Video_Sitemap_Core_Provider. + */ + abstract class WP_Sitemaps_Provider { + // phpcs:ignore Generic.Classes.DuplicateClassName.Found -- test stub. + + /** + * @var string + */ + protected $name = ''; + + /** + * @var string + */ + protected $object_type = ''; + + /** + * @param int $page_num Page. + * @param string $object_subtype Subtype. + * @return array + */ + abstract public function get_url_list( $page_num, $object_subtype = '' ); + + /** + * @param string $object_subtype Subtype. + * @return int + */ + abstract public function get_max_num_pages( $object_subtype = '' ); + } + } + + if ( ! class_exists( 'WPSEO_Meta' ) ) { + + /** + * Fake Yoast SEO meta API. + */ + class WPSEO_Meta { + + /** + * @param string $key Meta key without prefix. + * @param int $post_id Post ID. + * @return string + */ + public static function get_value( $key, $post_id = 0 ) { + return (string) ( $GLOBALS['rtgodam_stub']['yoast_meta'][ (int) $post_id ][ $key ] ?? '' ); + } + } + } + + if ( ! class_exists( 'WPSEO_Options' ) ) { + + /** + * Fake Yoast SEO options API. + */ + class WPSEO_Options { + + /** + * @param string $key Option key. + * @param mixed $default_value Default. + * @return mixed + */ + public static function get( $key, $default_value = null ) { + return $GLOBALS['rtgodam_stub']['yoast_options'][ $key ] ?? $default_value; + } + } + } +} + +namespace RankMath { + + if ( ! class_exists( '\RankMath\Helper' ) ) { + + /** + * Fake Rank Math helper. + */ + class Helper { + + /** + * @param int $post_id Post ID. + * @param bool $should_check_excluded_list Ignored. + * @return bool + */ + public static function is_post_indexable( $post_id, $should_check_excluded_list = true ) { + return ! in_array( (int) $post_id, $GLOBALS['rtgodam_stub']['rank_math_noindex'] ?? array(), true ); + } + + /** + * @param string $module Module slug. + * @return bool + */ + public static function is_module_active( $module ) { + return ! empty( $GLOBALS['rtgodam_stub']['rank_math_active'] ); + } + } + } +}