ext = new BlueskyThreadsExtension(); } /** Call a private/protected method by name and return its result. */ private function call(string $method, mixed ...$args): mixed { $ref = new ReflectionMethod(BlueskyThreadsExtension::class, $method); return $ref->invoke($this->ext, ...$args); } // ------------------------------------------------------------------------- // Feed parsing — verifies the extension detects Bluesky posts in a real feed // ------------------------------------------------------------------------- /** * Every in the hockeyviz RSS feed must match the Bluesky post URL * pattern used by the extension, confirming the feed is fully covered. * * Feed source: https://bsky.app/profile/did:plc:d4324t32vfi5xzydqbh2qdj3/rss */ public function testAllFeedLinksMatchBskyPattern(): void { $feed = simplexml_load_file(__DIR__ . '/fixtures/hockeyviz_feed.xml'); $pattern = '~bsky\.app/profile/([^/]+)/post/([^/?#\s]+)~'; $this->assertNotFalse($feed, 'RSS fixture must be parseable XML'); $this->assertGreaterThan(0, count($feed->channel->item), 'Feed must have at least one item'); foreach ($feed->channel->item as $item) { $this->assertMatchesRegularExpression( $pattern, (string) $item->link, "Link '{$item->link}' should match the Bluesky post URL pattern" ); } } /** * The specific thread post (2 replies) is present in the feed and its URL * is correctly identified as a Bluesky post by the extension's pattern. */ public function testThreadPostIsPresentInFeed(): void { $feed = simplexml_load_file(__DIR__ . '/fixtures/hockeyviz_feed.xml'); $threadPostLink = 'https://bsky.app/profile/hockeyviz.com/post/3mhtk7awhrp26'; $found = false; foreach ($feed->channel->item as $item) { if ((string) $item->link === $threadPostLink) { $found = true; break; } } $this->assertTrue($found, "Thread post {$threadPostLink} must be present in the feed fixture"); } /** * fetchThread() leaves entries whose link is not a Bluesky URL completely * untouched — no content mutation should occur. */ public function testFetchThreadIgnoresNonBskyEntries(): void { $entry = new FreshRSS_Entry(); $entry->_link('https://example.com/some/article'); $entry->_content('original content'); $result = $this->ext->fetchThread($entry); $this->assertSame('original content', $result->content()); } /** * fetchThread() recognises a valid Bluesky post URL and attempts enrichment. * We verify it does NOT leave the content untouched (even if the API fails * in CI, the URL detection code-path must be exercised). * * Because this calls the live API (or silently skips on failure), we just * assert the entry is returned without throwing. */ public function testFetchThreadHandlesBskyEntryWithoutThrowing(): void { $entry = new FreshRSS_Entry(); $entry->_link('https://bsky.app/profile/hockeyviz.com/post/3mhtk7awhrp26'); $entry->_content(''); $result = $this->ext->fetchThread($entry); $this->assertInstanceOf(FreshRSS_Entry::class, $result); } // ------------------------------------------------------------------------- // Thread rendering — given a fixture API response, HTML must be correct // ------------------------------------------------------------------------- /** * renderThread() on the 3mhtk7awhrp26 fixture (1 root + 2 replies) must * produce exactly 3 bsky-post elements and 1 bsky-replies wrapper. */ public function testRenderThreadWithTwoReplies(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $thread = $data['thread']; $html = $this->call('renderThread', $thread, true); $this->assertSame(3, substr_count($html, 'bsky-post'), 'Should render root post + 2 replies = 3 bsky-post elements'); $this->assertSame(1, substr_count($html, 'bsky-replies'), 'Should wrap replies in exactly one bsky-replies container'); } /** Root post carries the bordered card style; replies use lighter padding. */ public function testRootPostUsesCardStyle(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $html = $this->call('renderThread', $data['thread'], true); $this->assertStringContainsString('border:1px solid #cfd9de;border-radius:12px', $html, 'Root post must use the card border style'); } /** The root post text must appear in the rendered HTML. */ public function testRootPostTextIsRendered(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $html = $this->call('renderThread', $data['thread'], true); $this->assertStringContainsString('Gold Drafting', $html); $this->assertStringContainsString('hockeyviz.com', $html); } /** Reply post authors are not rendered; only quoted post authors are. */ public function testReplyAuthorsAreRendered(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $html = $this->call('renderThread', $data['thread'], true); $this->assertStringNotContainsString('example.bsky.social', $html, 'Reply author must not appear in thread posts'); $this->assertStringNotContainsString('hockeyfan.bsky.social', $html, 'Reply author must not appear in thread posts'); } /** The quoted/embedded post inside the root is rendered via renderQuotedRecord. */ public function testQuotedPostEmbedIsRendered(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $html = $this->call('renderThread', $data['thread'], true); // The quoted record contains the #GoldRace text $this->assertStringContainsString('GoldRace', $html, 'Quoted post text must be rendered as an embed'); } /** Quoted posts include a "View on Bluesky" link; thread posts do not. */ public function testViewOnBskyLinksArePresent(): void { $data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true); $html = $this->call('renderThread', $data['thread'], true); $this->assertStringNotContainsString('3mhtk7awhrp26', $html, 'Thread post rkey must not appear as a permalink'); $this->assertStringContainsString('3mhtjo3rtkn26', $html, 'Quoted post rkey must appear in its View on Bluesky link'); $this->assertStringContainsString('View on Bluesky', $html); } // ------------------------------------------------------------------------- // applyFacets — rich-text rendering // ------------------------------------------------------------------------- public function testApplyFacetsNoFacetsEscapesHtml(): void { $result = $this->call('applyFacets', '', []); $this->assertSame('<Hello & World>', $result); } public function testApplyFacetsRendersLink(): void { $text = 'Visit example.com now'; $facets = [[ 'index' => ['byteStart' => 6, 'byteEnd' => 17], 'features' => [['$type' => 'app.bsky.richtext.facet#link', 'uri' => 'https://example.com']], ]]; $html = $this->call('applyFacets', $text, $facets); $this->assertStringContainsString('assertStringContainsString('example.com', $html); } public function testApplyFacetsRendersMention(): void { $text = 'Hello @user.bsky.social today'; $facets = [[ 'index' => ['byteStart' => 6, 'byteEnd' => 23], 'features' => [['$type' => 'app.bsky.richtext.facet#mention', 'did' => 'did:plc:fakeid']], ]]; $html = $this->call('applyFacets', $text, $facets); $this->assertStringContainsString('href="https://bsky.app/profile/did:plc:fakeid"', $html); $this->assertStringContainsString('@user.bsky.social', $html); } public function testApplyFacetsRendersHashtag(): void { $text = 'Great game #GoldRace tonight'; $facets = [[ 'index' => ['byteStart' => 11, 'byteEnd' => 21], 'features' => [['$type' => 'app.bsky.richtext.facet#tag', 'tag' => 'GoldRace']], ]]; $html = $this->call('applyFacets', $text, $facets); $this->assertStringContainsString('href="https://bsky.app/hashtag/GoldRace"', $html); $this->assertStringContainsString('#GoldRace', $html); } // ------------------------------------------------------------------------- // needsRefetch — staleness rules // ------------------------------------------------------------------------- public function testNeedsRefetchWhenNeverCached(): void { $this->assertTrue($this->call('needsRefetch', time() - 60, null), 'Always refetch when there is no cache entry'); } public function testNoRefetchWhenPostIsFrozen(): void { $publishedAt = time() - (8 * 86400); // 8 days ago $fetchedAt = time() - 3600; // fetched 1 hour ago $this->assertFalse($this->call('needsRefetch', $publishedAt, $fetchedAt), 'Posts older than 7 days are frozen and must not be re-fetched'); } public function testRefetchRecentPostWithStaleCache(): void { $publishedAt = time() - 1800; // 30 min old → window is 10 min $fetchedAt = time() - 900; // cached 15 min ago → stale $this->assertTrue($this->call('needsRefetch', $publishedAt, $fetchedAt), 'Cache older than the refresh window must trigger a re-fetch'); } public function testNoRefetchRecentPostWithFreshCache(): void { $publishedAt = time() - 1800; // 30 min old → window is 10 min $fetchedAt = time() - 300; // cached 5 min ago → fresh $this->assertFalse($this->call('needsRefetch', $publishedAt, $fetchedAt), 'Cache newer than the refresh window must NOT trigger a re-fetch'); } public function testRefetchWindows(): void { $cases = [ // [post age seconds, cache age seconds, expect refetch] [1800, 601, true], // < 1 h post, > 10 min cache → stale [1800, 599, false], // < 1 h post, < 10 min cache → fresh [7200, 3601, true], // < 24 h post, > 1 h cache → stale [7200, 3599, false], // < 24 h post, < 1 h cache → fresh [3 * 86400, 43201, true], // < 7 d post, > 12 h cache → stale [3 * 86400, 43199, false], // < 7 d post, < 12 h cache → fresh ]; foreach ($cases as [$postAge, $cacheAge, $expectRefetch]) { $publishedAt = time() - $postAge; $fetchedAt = time() - $cacheAge; $this->assertSame( $expectRefetch, $this->call('needsRefetch', $publishedAt, $fetchedAt), "postAge={$postAge}s cacheAge={$cacheAge}s" ); } } // ------------------------------------------------------------------------- // handleConfigureAction — user config // ------------------------------------------------------------------------- public function testHandleConfigureActionSavesDepth(): void { Minz_Request::simulatePost(['depth' => '25']); $this->ext->handleConfigureAction(); $this->assertSame(25, $this->ext->getUserConfigurationValue('depth')); } public function testHandleConfigureActionClampsMinDepth(): void { // 0 is falsy after (int) cast, so the code falls back to the default of 10. // A negative value exercises the max(1, ...) clamp. Minz_Request::simulatePost(['depth' => '-5']); $this->ext->handleConfigureAction(); $this->assertSame(1, $this->ext->getUserConfigurationValue('depth')); } public function testHandleConfigureActionClampsMaxDepth(): void { Minz_Request::simulatePost(['depth' => '9999']); $this->ext->handleConfigureAction(); $this->assertSame(1000, $this->ext->getUserConfigurationValue('depth')); } }