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'));
}
}