diff --git a/.gitignore b/.gitignore index 284e8b0bc..1274c2de7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ assets/packaged .vscode/settings.json .idea +.phpunit.result.cache tests/cypress/screenshots docs/.vitepress/dist /vendor-prefixed diff --git a/docs/en/documentation/advanced-functionality/index.md b/docs/en/documentation/advanced-functionality/index.md index 137e0f352..73cf7e2ee 100644 --- a/docs/en/documentation/advanced-functionality/index.md +++ b/docs/en/documentation/advanced-functionality/index.md @@ -5,3 +5,4 @@ * [Configure cache](cache) * [Change default values for timeframe creation](change-timeframe-creation-defaults) * [Hooks and filters](hooks-and-filters) + * [Sitemaps and SEO](sitemaps-and-seo) diff --git a/docs/en/documentation/advanced-functionality/sitemaps-and-seo.md b/docs/en/documentation/advanced-functionality/sitemaps-and-seo.md new file mode 100644 index 000000000..6d8076eaa --- /dev/null +++ b/docs/en/documentation/advanced-functionality/sitemaps-and-seo.md @@ -0,0 +1,90 @@ +# Sitemaps and SEO + +So that **Items** and **Locations** can be discovered by search engines and listed in your site's +sitemap, a small amount of configuration is recommended. CommonsBooking handles the technical +groundwork automatically — you just need to choose how to expose it. + +--- + +## What CommonsBooking does automatically + +CommonsBooking registers its custom post types with the correct WordPress flags so that the right +content is discoverable and the right content stays private: + +| Post type | Public | In sitemap by default | +|---|---|---| +| Item (`cb_item`) | Yes | **Yes** | +| Location (`cb_location`) | Yes | **Yes** | +| Timeframe (`cb_timeframe`) | No | No | +| Booking (`cb_booking`) | No | No | +| Restriction (`cb_restriction`) | — | **Excluded by plugin** | +| Map (`cb_map`) | — | **Excluded by plugin** | + +`cb_restriction` and `cb_map` are excluded from the sitemap via the `wp_sitemaps_post_types` +filter even though they are technically public post types (required for front-end rendering). They +contain administrative configuration, not content that should be indexed. + +--- + +## Recommended setup: install an SEO plugin + +WordPress 5.5 includes a built-in sitemap at `/wp-sitemap.xml`. For most sites, installing a +dedicated SEO plugin gives you better control over titles, descriptions, canonical URLs, and +structured data. We recommend one of the following: + +### Yoast SEO + +[Yoast SEO](https://yoast.com/wordpress/plugins/seo/) is the most widely used SEO plugin for +WordPress. After installation: + +1. Go to **Yoast SEO → Search Appearance → Content Types**. +2. Find **Items** (`cb_item`) and **Locations** (`cb_location`). +3. Set **Show in search results** to **Yes** for both. +4. Optionally customise the SEO title and meta description templates using Yoast's variables + (e.g. `%%title%% – %%sitename%%`). +5. Yoast will automatically generate a sitemap entry for each published Item and Location. + +### RankMath + +[RankMath](https://rankmath.com/) is a lightweight alternative with a guided setup wizard. After +installation: + +1. Go to **RankMath → Titles & Meta → Items** (and repeat for **Locations**). +2. Enable **Add to sitemap**. +3. Set a sensible title pattern, e.g. `%title% - %sitename%`. +4. RankMath will include Items and Locations in its sitemap automatically. + +--- + +## Using the WordPress core sitemap (no SEO plugin) + +If you prefer not to install an SEO plugin, the core sitemap at `/wp-sitemap.xml` already +includes Items and Locations out of the box. Individual post type sitemaps are available at: + +``` +/wp-sitemap-posts-cb_item-1.xml +/wp-sitemap-posts-cb_location-1.xml +``` + +You can submit these URLs directly to Google Search Console or Bing Webmaster Tools. + +--- + +## Excluding specific posts from the sitemap + +To exclude individual Items or Locations from the sitemap (e.g. items in draft or under +maintenance), set the post status to **Draft** rather than **Published**. Both the core sitemap +and SEO plugins only index `publish`-status posts. + +With Yoast SEO you can also exclude a single post by opening its editor and setting +**Yoast SEO → Advanced → Allow search engines to show this post in search results** to **No**. + +--- + +## Structured data (JSON-LD) + +For richer search results (e.g. showing the item name and availability directly in Google), +structured data markup is needed. This is beyond CommonsBooking's scope — a developer can add +`wp_head` hooks with custom JSON-LD using the post meta fields CommonsBooking stores (location +address, item description, etc.). See the +[hooks and filters reference](hooks-and-filters) for available data access points. diff --git a/includes/OptionsArray.php b/includes/OptionsArray.php index 0601828bc..cc5dbd4c1 100644 --- a/includes/OptionsArray.php +++ b/includes/OptionsArray.php @@ -1587,6 +1587,18 @@ ), ], ), + 'rssfeed' => array( + 'title' => esc_html__( 'RSS Feed', 'commonsbooking' ), + 'desc' => commonsbooking_sanitizeHTML( __( 'Enables public RSS feeds for Items, Locations and Timeframes so users can subscribe to updates in any feed reader.', 'commonsbooking' ) ), + 'id' => 'rss_feed_group', + 'fields' => [ + array( + 'name' => esc_html__( 'Enable RSS feed', 'commonsbooking' ), + 'id' => 'rss_feed_enabled', + 'type' => 'checkbox', + ), + ], + ), 'experimental' => array( 'title' => commonsbooking_sanitizeHTML( __( 'Advanced caching settings', 'commonsbooking' ) ), 'id' => 'caching_group', diff --git a/src/Plugin.php b/src/Plugin.php index b7e143506..3a9ff2f85 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -14,6 +14,7 @@ use CommonsBooking\Service\Cache; use CommonsBooking\Service\Scheduler; use CommonsBooking\Service\iCalendar; +use CommonsBooking\Service\RssFeed; use CommonsBooking\Service\Upgrade; use CommonsBooking\Settings\Settings; use CommonsBooking\Repository\BookingCodes; @@ -831,6 +832,19 @@ function () { // iCal rewrite iCalendar::initRewrite(); + + // RSS feed rewrite + RssFeed::initRewrite(); + + // Exclude admin-only CPTs from WP core sitemaps (WP 5.5+). + // Yoast SEO and RankMath respect this same filter. + // cb_item and cb_location are intentionally left in (public content). + add_filter( 'wp_sitemaps_post_types', static function ( array $types ): array { + unset( $types['cb_restriction'] ); + unset( $types['cb_map'] ); + return $types; + } ); + } /** diff --git a/src/Service/RssFeed.php b/src/Service/RssFeed.php new file mode 100644 index 000000000..3ec687f13 --- /dev/null +++ b/src/Service/RssFeed.php @@ -0,0 +1,294 @@ + 'Items', + 'cb_location' => 'Locations', + 'cb_timeframe' => 'Timeframes', + ]; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Registers rewrite rules and query vars for the RSS feed. + * Call from Plugin::init() — only hooks up when the setting is enabled. + */ + public static function initRewrite(): void { + if ( ! self::isFeedEnabled() ) { + return; + } + + add_action( 'wp_loaded', static function () { + add_rewrite_rule( + self::URL_SLUG . '/([^/]+)/?$', + 'index.php?' . self::URL_SLUG . '=1&' . self::QUERY_TYPE . '=$matches[1]', + 'top' + ); + } ); + + add_filter( 'query_vars', static function ( array $vars ): array { + $vars[] = self::URL_SLUG; + $vars[] = self::QUERY_TYPE; + return $vars; + } ); + + add_action( 'parse_request', static function ( \WP $wp ): void { + if ( empty( $wp->query_vars[ self::URL_SLUG ] ) ) { + return; + } + $postType = isset( $wp->query_vars[ self::QUERY_TYPE ] ) + ? sanitize_key( $wp->query_vars[ self::QUERY_TYPE ] ) + : ''; + + self::outputFeed( $postType ); + } ); + } + + /** + * Returns whether the RSS feed feature is enabled in settings. + */ + public static function isFeedEnabled(): bool { + return Settings::getOption( + COMMONSBOOKING_PLUGIN_SLUG . '_options_advanced-options', + 'rss_feed_enabled' + ) === 'on'; + } + + /** + * Returns the subscription URL for a given post type. + * + * @param string $postType One of the SUPPORTED_POST_TYPES values. + * @return string Absolute URL. + */ + public static function getFeedUrl( string $postType ): string { + return add_query_arg( + [ + self::URL_SLUG => '1', + self::QUERY_TYPE => $postType, + ], + trailingslashit( get_site_url() ) + ); + } + + /** + * Returns the list of post types that have RSS feeds. + * + * @return string[] + */ + public static function getSupportedPostTypes(): array { + return self::SUPPORTED_POST_TYPES; + } + + /** + * Returns true when $postType has a feed. + */ + public static function isValidPostType( string $postType ): bool { + return in_array( $postType, self::SUPPORTED_POST_TYPES, true ); + } + + // ------------------------------------------------------------------------- + // Feed rendering + // ------------------------------------------------------------------------- + + /** + * Sends RSS 2.0 feed headers and body for the requested post type. + * Terminates script execution afterwards. + * + * @param string $postType + */ + public static function outputFeed( string $postType ): void { + if ( ! self::isValidPostType( $postType ) ) { + status_header( 404 ); + wp_die( esc_html__( 'RSS feed not found for this post type.', 'commonsbooking' ), 404 ); + } + + $posts = self::fetchPosts( $postType ); + $xml = self::renderFeedXml( $posts, $postType ); + + header( 'Content-Type: application/rss+xml; charset=UTF-8' ); + header( 'X-Robots-Tag: noindex' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $xml; + exit; + } + + /** + * Renders and returns an RSS 2.0 XML string. + * + * Separated from outputFeed() to make it directly testable without HTTP. + * + * @param \WP_Post[]|\stdClass[] $posts Array of WP_Post objects. + * @param string $postType Post type slug (used for channel metadata). + * @return string Well-formed RSS 2.0 XML. + */ + public static function renderFeedXml( array $posts, string $postType ): string { + $siteUrl = get_site_url() ?: 'http://localhost'; + $siteTitle = get_bloginfo( 'name' ) ?: 'CommonsBooking'; + $label = self::POST_TYPE_LABELS[ $postType ] ?? $postType; + $feedUrl = self::getFeedUrl( $postType ); + $lastBuild = gmdate( 'r' ); + + $channelTitle = $siteTitle . ' — ' . $label; + $channelDesc = sprintf( 'RSS feed for %s', $label ); + + $dom = new \DOMDocument( '1.0', 'UTF-8' ); + $dom->formatOutput = true; + + // + $rss = $dom->createElement( 'rss' ); + $rss->setAttribute( 'version', '2.0' ); + $rss->setAttribute( 'xmlns:atom', 'http://www.w3.org/2005/Atom' ); + $dom->appendChild( $rss ); + + // + $channel = $dom->createElement( 'channel' ); + $rss->appendChild( $channel ); + + self::appendTextNode( $dom, $channel, 'title', $channelTitle ); + self::appendTextNode( $dom, $channel, 'link', $siteUrl ); + self::appendTextNode( $dom, $channel, 'description', $channelDesc ); + self::appendTextNode( $dom, $channel, 'language', get_bloginfo( 'language' ) ?: 'en' ); + self::appendTextNode( $dom, $channel, 'lastBuildDate', $lastBuild ); + + // + $atomLink = $dom->createElement( 'atom:link' ); + $atomLink->setAttribute( 'href', $feedUrl ); + $atomLink->setAttribute( 'rel', 'self' ); + $atomLink->setAttribute( 'type', 'application/rss+xml' ); + $channel->appendChild( $atomLink ); + + // for each post + foreach ( $posts as $post ) { + $channel->appendChild( self::buildItemNode( $dom, $post ) ); + } + + return $dom->saveXML(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Fetches the most recent published posts for a given post type. + * + * @param string $postType + * @return \WP_Post[] + */ + private static function fetchPosts( string $postType ): array { + $query = new WP_Query( [ + 'post_type' => $postType, + 'post_status' => 'publish', + 'posts_per_page' => self::ITEMS_PER_FEED, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => true, + ] ); + + return $query->posts ?: []; + } + + /** + * Builds a DOMElement for a single post. + * + * @param \DOMDocument $dom + * @param \WP_Post|\stdClass $post + * @return \DOMElement + */ + private static function buildItemNode( \DOMDocument $dom, $post ): \DOMElement { + $item = $dom->createElement( 'item' ); + + $title = isset( $post->post_title ) ? $post->post_title : ''; + $link = get_permalink( $post->ID ); + $pubDate = isset( $post->post_date_gmt ) + ? gmdate( 'r', strtotime( $post->post_date_gmt ) ) + : gmdate( 'r' ); + $guid = $link ?: ( get_site_url() . '/?p=' . $post->ID ); + $content = isset( $post->post_content ) ? $post->post_content : ''; + $excerpt = isset( $post->post_excerpt ) && $post->post_excerpt !== '' + ? $post->post_excerpt + : wp_trim_words( $content, 55 ); + + self::appendTextNode( $dom, $item, 'title', $title ); + self::appendTextNode( $dom, $item, 'link', $link ?: '' ); + self::appendTextNode( $dom, $item, 'pubDate', $pubDate ); + + $guidEl = $dom->createElement( 'guid' ); + $guidEl->setAttribute( 'isPermaLink', 'true' ); + $guidEl->appendChild( $dom->createTextNode( $guid ) ); + $item->appendChild( $guidEl ); + + // Description wrapped in CDATA so HTML is preserved safely + $desc = $dom->createElement( 'description' ); + $desc->appendChild( $dom->createCDATASection( $excerpt ) ); + $item->appendChild( $desc ); + + return $item; + } + + /** + * Creates and appends a text-content element to a parent node. + * + * @param \DOMDocument $dom + * @param \DOMElement $parent + * @param string $tagName + * @param string $value + */ + private static function appendTextNode( + \DOMDocument $dom, + \DOMElement $parent, + string $tagName, + string $value + ): void { + $el = $dom->createElement( $tagName ); + $el->appendChild( $dom->createTextNode( $value ) ); + $parent->appendChild( $el ); + } +} diff --git a/tests/php/PluginTest.php b/tests/php/PluginTest.php index ce7bfd027..ac6845517 100644 --- a/tests/php/PluginTest.php +++ b/tests/php/PluginTest.php @@ -9,8 +9,32 @@ class PluginTest extends CustomPostTypeTest { - private $postIDs = []; + + /** + * wp_sitemaps_post_types filter must include public content CPTs and + * exclude admin-only CPTs that should never be indexed. + */ + public function testSitemapPostTypesFilterExcludesAdminCpts() { + ( new Plugin() )->init(); + + // Simulate what WP core does: build the initial list from all registered CPTs + $registered = array_filter( + get_post_types( [], 'objects' ), + static fn( $cpt ) => $cpt->public + ); + + $filtered = apply_filters( 'wp_sitemaps_post_types', $registered ); + + // Admin-only CPTs must not be in the sitemap + $this->assertArrayNotHasKey( 'cb_restriction', $filtered ); + $this->assertArrayNotHasKey( 'cb_map', $filtered ); + + // Public content CPTs must remain + $this->assertArrayHasKey( 'cb_item', $filtered ); + $this->assertArrayHasKey( 'cb_location', $filtered ); + } + public function testGetCustomPostTypes() { $this->assertIsArray( Plugin::getCustomPostTypes() ); // make sure, that we also have a model for each custom post type diff --git a/tests/php/Service/RssFeedTest.php b/tests/php/Service/RssFeedTest.php new file mode 100644 index 000000000..a18d8e60a --- /dev/null +++ b/tests/php/Service/RssFeedTest.php @@ -0,0 +1,290 @@ +assertTrue( RssFeed::isValidPostType( 'cb_item' ) ); + } + + public function testIsValidPostTypeReturnsTrueForCbLocation() { + $this->assertTrue( RssFeed::isValidPostType( 'cb_location' ) ); + } + + public function testIsValidPostTypeReturnsTrueForCbTimeframe() { + $this->assertTrue( RssFeed::isValidPostType( 'cb_timeframe' ) ); + } + + public function testIsValidPostTypeReturnsFalseForUnknownType() { + $this->assertFalse( RssFeed::isValidPostType( 'post' ) ); + } + + public function testIsValidPostTypeReturnsFalseForEmptyString() { + $this->assertFalse( RssFeed::isValidPostType( '' ) ); + } + + public function testIsValidPostTypeReturnsFalseForCbBooking() { + // Bookings are private – they must not be exposed via public RSS. + $this->assertFalse( RssFeed::isValidPostType( 'cb_booking' ) ); + } + + // ------------------------------------------------------------------------- + // Supported post types list + // ------------------------------------------------------------------------- + + public function testSupportedPostTypesContainsAllPublicCpts() { + $supported = RssFeed::getSupportedPostTypes(); + $this->assertContains( 'cb_item', $supported ); + $this->assertContains( 'cb_location', $supported ); + $this->assertContains( 'cb_timeframe', $supported ); + } + + public function testSupportedPostTypesDoesNotContainPrivateCpts() { + $supported = RssFeed::getSupportedPostTypes(); + $this->assertNotContains( 'cb_booking', $supported ); + $this->assertNotContains( 'cb_restriction', $supported ); + } + + // ------------------------------------------------------------------------- + // Feed URL generation + // ------------------------------------------------------------------------- + + public function testGetFeedUrlReturnsString() { + $url = RssFeed::getFeedUrl( 'cb_item' ); + $this->assertIsString( $url ); + } + + public function testGetFeedUrlContainsRssSlug() { + $url = RssFeed::getFeedUrl( 'cb_item' ); + $this->assertStringContainsString( RssFeed::URL_SLUG, $url ); + } + + public function testGetFeedUrlContainsPostType() { + $url = RssFeed::getFeedUrl( 'cb_location' ); + $this->assertStringContainsString( 'cb_location', $url ); + } + + public function testGetFeedUrlDiffersPerPostType() { + $itemUrl = RssFeed::getFeedUrl( 'cb_item' ); + $locationUrl = RssFeed::getFeedUrl( 'cb_location' ); + $this->assertNotEquals( $itemUrl, $locationUrl ); + } + + // ------------------------------------------------------------------------- + // RSS XML structure + // ------------------------------------------------------------------------- + + public function testRenderFeedXmlReturnsNonEmptyString() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $this->assertIsString( $xml ); + $this->assertNotEmpty( $xml ); + } + + public function testRenderFeedXmlIsWellFormedXml() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $loaded = @$doc->loadXML( $xml ); + $this->assertTrue( $loaded, 'renderFeedXml must return well-formed XML.' ); + } + + public function testRenderFeedXmlHasRss2Root() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $root = $doc->documentElement; + $this->assertEquals( 'rss', $root->nodeName ); + $this->assertEquals( '2.0', $root->getAttribute( 'version' ) ); + } + + public function testRenderFeedXmlHasChannel() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $channels = $doc->getElementsByTagName( 'channel' ); + $this->assertEquals( 1, $channels->length ); + } + + public function testRenderFeedXmlChannelHasTitle() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $channel = $doc->getElementsByTagName( 'channel' )->item( 0 ); + $titles = $channel->getElementsByTagName( 'title' ); + $this->assertGreaterThanOrEqual( 1, $titles->length ); + $this->assertNotEmpty( $titles->item( 0 )->textContent ); + } + + public function testRenderFeedXmlChannelHasLink() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $channel = $doc->getElementsByTagName( 'channel' )->item( 0 ); + $links = $channel->getElementsByTagName( 'link' ); + $this->assertGreaterThanOrEqual( 1, $links->length ); + } + + public function testRenderFeedXmlChannelHasDescription() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $channel = $doc->getElementsByTagName( 'channel' )->item( 0 ); + $descriptions = $channel->getElementsByTagName( 'description' ); + $this->assertGreaterThanOrEqual( 1, $descriptions->length ); + } + + public function testRenderFeedXmlChannelHasLastBuildDate() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $channel = $doc->getElementsByTagName( 'channel' )->item( 0 ); + $dates = $channel->getElementsByTagName( 'lastBuildDate' ); + $this->assertEquals( 1, $dates->length ); + } + + // ------------------------------------------------------------------------- + // RSS items from posts + // ------------------------------------------------------------------------- + + public function testRenderFeedXmlContainsPostAsItem() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $items = $doc->getElementsByTagName( 'item' ); + $this->assertEquals( 1, $items->length ); + } + + public function testRenderFeedXmlItemHasTitle() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $item = $doc->getElementsByTagName( 'item' )->item( 0 ); + $title = $item->getElementsByTagName( 'title' )->item( 0 ); + $this->assertNotNull( $title ); + $this->assertNotEmpty( $title->textContent ); + } + + public function testRenderFeedXmlItemHasLink() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $item = $doc->getElementsByTagName( 'item' )->item( 0 ); + $link = $item->getElementsByTagName( 'link' )->item( 0 ); + $this->assertNotNull( $link ); + $this->assertNotEmpty( $link->textContent ); + } + + public function testRenderFeedXmlItemHasPubDate() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $item = $doc->getElementsByTagName( 'item' )->item( 0 ); + $pubDate = $item->getElementsByTagName( 'pubDate' )->item( 0 ); + $this->assertNotNull( $pubDate ); + $this->assertNotEmpty( $pubDate->textContent ); + } + + public function testRenderFeedXmlItemHasGuid() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $item = $doc->getElementsByTagName( 'item' )->item( 0 ); + $guid = $item->getElementsByTagName( 'guid' )->item( 0 ); + $this->assertNotNull( $guid ); + $this->assertNotEmpty( $guid->textContent ); + } + + public function testRenderFeedXmlItemHasDescription() { + $posts = [ get_post( $this->itemId ) ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $item = $doc->getElementsByTagName( 'item' )->item( 0 ); + $description = $item->getElementsByTagName( 'description' )->item( 0 ); + $this->assertNotNull( $description ); + } + + public function testRenderFeedXmlEmptyPostsHasNoItems() { + $xml = RssFeed::renderFeedXml( [], 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $items = $doc->getElementsByTagName( 'item' ); + $this->assertEquals( 0, $items->length ); + } + + public function testRenderFeedXmlCorrectItemCountForMultiplePosts() { + $posts = [ + get_post( $this->itemId ), + get_post( $this->locationId ), + ]; + $xml = RssFeed::renderFeedXml( $posts, 'cb_item' ); + $doc = new \DOMDocument(); + $doc->loadXML( $xml ); + $items = $doc->getElementsByTagName( 'item' ); + $this->assertEquals( 2, $items->length ); + } + + // ------------------------------------------------------------------------- + // Channel title reflects post type label + // ------------------------------------------------------------------------- + + public function testChannelTitleDiffersPerPostType() { + $xmlItem = RssFeed::renderFeedXml( [], 'cb_item' ); + $xmlLocation = RssFeed::renderFeedXml( [], 'cb_location' ); + + $docItem = new \DOMDocument(); + $docLocation = new \DOMDocument(); + $docItem->loadXML( $xmlItem ); + $docLocation->loadXML( $xmlLocation ); + + $titleItem = $docItem->getElementsByTagName( 'channel' )->item( 0 ) + ->getElementsByTagName( 'title' )->item( 0 )->textContent; + $titleLocation = $docLocation->getElementsByTagName( 'channel' )->item( 0 ) + ->getElementsByTagName( 'title' )->item( 0 )->textContent; + + $this->assertNotEquals( $titleItem, $titleLocation ); + } + + // ------------------------------------------------------------------------- + // Settings gate + // ------------------------------------------------------------------------- + + public function testIsFeedEnabledReturnsFalseWhenSettingOff() { + Settings::updateOption( + COMMONSBOOKING_PLUGIN_SLUG . '_options_advanced-options', + 'rss_feed_enabled', + 'off' + ); + $this->assertFalse( RssFeed::isFeedEnabled() ); + } + + public function testIsFeedEnabledReturnsTrueWhenSettingOn() { + Settings::updateOption( + COMMONSBOOKING_PLUGIN_SLUG . '_options_advanced-options', + 'rss_feed_enabled', + 'on' + ); + $this->assertTrue( RssFeed::isFeedEnabled() ); + } +}