Add PHPUnit test suite, Gitea CI, and fix broken URL pattern
All checks were successful
CI / test (push) Successful in 1m41s
All checks were successful
CI / test (push) Successful in 1m41s
- Add 22 unit tests covering feed parsing, thread rendering, facets, needsRefetch staleness windows, and handleConfigureAction - Add FreshRSS class stubs so tests run without a full FreshRSS install - Add RSS feed fixture (hockeyviz.com snapshot) and thread API fixture for post 3mhtk7awhrp26 (1 root + 2 replies) - Add Gitea Actions workflow (.gitea/workflows/ci.yml) running on PHP 8.2 - Fix POST_URL_PATTERN: using '#' as PCRE delimiter with '#' inside a character class caused PHP to close the pattern early, so no Bluesky URL ever matched; switch delimiter to '~' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
26
.gitea/workflows/ci.yml
Normal file
26
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: json, simplexml
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Run tests
|
||||
run: vendor/bin/phpunit
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
vendor/
|
||||
.phpunit.result.cache
|
||||
12
composer.json
Normal file
12
composer.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "unsupervised/freshrss-bluesky-threads",
|
||||
"description": "FreshRSS extension: expand Bluesky posts into full reply threads",
|
||||
"type": "freshrss-extension",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5 || ^11.0"
|
||||
}
|
||||
}
|
||||
1802
composer.lock
generated
Normal file
1802
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
final class BlueskyThreadsExtension extends Minz_Extension {
|
||||
|
||||
private const API_BASE = 'https://public.api.bsky.app/xrpc';
|
||||
private const POST_URL_PATTERN = '#bsky\.app/profile/([^/]+)/post/([^/?#\s]+)#';
|
||||
private const POST_URL_PATTERN = '~bsky\.app/profile/([^/]+)/post/([^/?#\s]+)~';
|
||||
|
||||
// How long a cached thread is considered fresh, based on the post's age.
|
||||
// Posts older than FREEZE_AGE are never re-fetched.
|
||||
|
||||
11
phpunit.xml
Normal file
11
phpunit.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="BlueskyThreads">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
305
tests/BlueskyThreadsTest.php
Normal file
305
tests/BlueskyThreadsTest.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for BlueskyThreadsExtension.
|
||||
*
|
||||
* Private methods are exercised via ReflectionMethod so the public API of the
|
||||
* extension stays untouched while still allowing granular unit coverage.
|
||||
*/
|
||||
class BlueskyThreadsTest extends TestCase {
|
||||
|
||||
private BlueskyThreadsExtension $ext;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
protected function setUp(): void {
|
||||
Minz_Request::reset();
|
||||
$this->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 <link> 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);
|
||||
}
|
||||
|
||||
/** Both reply authors must appear in the rendered HTML. */
|
||||
public function testReplyAuthorsAreRendered(): void {
|
||||
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
|
||||
$html = $this->call('renderThread', $data['thread'], true);
|
||||
|
||||
$this->assertStringContainsString('example.bsky.social', $html, 'First reply author must be present');
|
||||
$this->assertStringContainsString('hockeyfan.bsky.social', $html, 'Second reply author must be present');
|
||||
}
|
||||
|
||||
/** 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');
|
||||
}
|
||||
|
||||
/** Every post must include a "View on Bluesky" link pointing to the right URL. */
|
||||
public function testViewOnBskyLinksArePresent(): void {
|
||||
$data = json_decode(file_get_contents(__DIR__ . '/fixtures/thread_3mhtk7awhrp26.json'), true);
|
||||
$html = $this->call('renderThread', $data['thread'], true);
|
||||
|
||||
$this->assertStringContainsString('3mhtk7awhrp26', $html, 'Root post rkey must appear in a Bluesky link');
|
||||
$this->assertStringContainsString('View on Bluesky', $html);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// applyFacets — rich-text rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testApplyFacetsNoFacetsEscapesHtml(): void {
|
||||
$result = $this->call('applyFacets', '<Hello & World>', []);
|
||||
$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('<a href="https://example.com"', $html);
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
6
tests/bootstrap.php
Normal file
6
tests/bootstrap.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/stubs/freshrss_stubs.php';
|
||||
require_once __DIR__ . '/../extension.php';
|
||||
107
tests/fixtures/hockeyviz_feed.xml
vendored
Normal file
107
tests/fixtures/hockeyviz_feed.xml
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Snapshot of https://bsky.app/profile/did:plc:d4324t32vfi5xzydqbh2qdj3/rss fetched 2026-03-26 -->
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<description>Math, hockey, viz, apocrypha</description>
|
||||
<link>https://bsky.app/profile/hockeyviz.com</link>
|
||||
<title>@hockeyviz.com - Micah McCurdy</title>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhxruphxfz2q</link>
|
||||
<description>No possible eliminations or qualifications today, will be a few days lull I expect.</description>
|
||||
<pubDate>26 Mar 2026 14:11 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhxruphxfz2q</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhxnes7zvw2k</link>
|
||||
<description>Yesterday's games:</description>
|
||||
<pubDate>26 Mar 2026 12:50 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhxnes7zvw2k</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhxidqqzwc26</link>
|
||||
<description>The New York Rangers are the second team to be eliminated from playoff contention.</description>
|
||||
<pubDate>26 Mar 2026 11:20 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhxidqqzwc26</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhw7rraxo72t</link>
|
||||
<description>The site being so old is weird to me now because the old charts are annoying to me spiritually.</description>
|
||||
<pubDate>25 Mar 2026 23:14 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhw7rraxo72t</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhvymqtksm2l</link>
|
||||
<pubDate>25 Mar 2026 21:06 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhvymqtksm2l</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhvo7humyx2j</link>
|
||||
<description>hockey viz dot com snubbed again

[contains quote post or other embedded content]</description>
|
||||
<pubDate>25 Mar 2026 18:00 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhvo7humyx2j</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhv7532iwp2p</link>
|
||||
<description>If Toronto beat New York tonight in any fashion, then the Rangers will be eliminated from playoff contention.</description>
|
||||
<pubDate>25 Mar 2026 13:30 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhv7532iwp2p</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhuuvbngrc2g</link>
|
||||
<description>Yesterday's games:</description>
|
||||
<pubDate>25 Mar 2026 10:27 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhuuvbngrc2g</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhtt6lqhsk2b</link>
|
||||
<description>Moms in the crowd smiling when their kids score their first goal >>></description>
|
||||
<pubDate>25 Mar 2026 00:24 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhtt6lqhsk2b</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhtq6pbhwn2p</link>
|
||||
<description>Little-known HockeyViz feature that's fun on nights with lots of games.</description>
|
||||
<pubDate>24 Mar 2026 23:30 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhtq6pbhwn2p</guid>
|
||||
</item>
|
||||
|
||||
<!-- This is the thread post with 2 replies used in rendering tests -->
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhtk7awhrp26</link>
|
||||
<description>Discussion about Gold Drafting always focusses on tanking, but really eliminating tanking discourse is a (very real) side-benefit.

[contains quote post or other embedded content]</description>
|
||||
<pubDate>24 Mar 2026 21:43 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhtk7awhrp26</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhtjo3rtkn26</link>
|
||||
<description>The NHL, and many other leagues, would be improved by adopting Gold drafting. #GoldRace</description>
|
||||
<pubDate>24 Mar 2026 21:33 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhtjo3rtkn26</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhpqxoumjl2b</link>
|
||||
<description>The Vancouver Canucks are the first team to be eliminated from playoff contention this season.</description>
|
||||
<pubDate>23 Mar 2026 09:33 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhpqxoumjl2b</guid>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<link>https://bsky.app/profile/hockeyviz.com/post/3mhpqwxte4n2l</link>
|
||||
<description>The Dallas Stars are the second team to qualify for the 2025-2026 playoffs.</description>
|
||||
<pubDate>23 Mar 2026 09:33 +0000</pubDate>
|
||||
<guid isPermaLink="false">at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhpqwxte4n2l</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
91
tests/fixtures/thread_3mhtk7awhrp26.json
vendored
Normal file
91
tests/fixtures/thread_3mhtk7awhrp26.json
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"thread": {
|
||||
"$type": "app.bsky.feed.defs#threadViewPost",
|
||||
"post": {
|
||||
"uri": "at://did:plc:d4324t32vfi5xzydqbh2qdj3/app.bsky.feed.post/3mhtk7awhrp26",
|
||||
"cid": "bafyreicfake1",
|
||||
"author": {
|
||||
"did": "did:plc:d4324t32vfi5xzydqbh2qdj3",
|
||||
"handle": "hockeyviz.com",
|
||||
"displayName": "Micah McCurdy",
|
||||
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:d4324t32vfi5xzydqbh2qdj3/bafkreifake1@jpeg"
|
||||
},
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Discussion about Gold Drafting always focusses on tanking, but really eliminating tanking discourse is a (very real) side-benefit. The primary benefit is that fans of bad teams get to enjoy the few late-season wins that they get, without having to add any games to the schedule.",
|
||||
"createdAt": "2026-03-24T21:43:00.000Z",
|
||||
"facets": []
|
||||
},
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.record#view",
|
||||
"record": {
|
||||
"$type": "app.bsky.embed.record#viewRecord",
|
||||
"uri": "at://did:plc:otherfake/app.bsky.feed.post/3mhtjo3rtkn26",
|
||||
"author": {
|
||||
"handle": "hockeyviz.com",
|
||||
"displayName": "Micah McCurdy"
|
||||
},
|
||||
"value": {
|
||||
"text": "The NHL, and many other leagues, would be improved by adopting Gold drafting, which makes the existing schedule more exciting for fans, eliminates tanking discourse, and gives the best draft picks (on average) to the weakest teams, who need them the most. This is the 2025-2026 thread. #GoldRace",
|
||||
"facets": []
|
||||
},
|
||||
"embeds": []
|
||||
}
|
||||
},
|
||||
"replyCount": 2,
|
||||
"repostCount": 4,
|
||||
"likeCount": 31,
|
||||
"indexedAt": "2026-03-24T21:43:01.000Z"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"$type": "app.bsky.feed.defs#threadViewPost",
|
||||
"post": {
|
||||
"uri": "at://did:plc:replier1fake/app.bsky.feed.post/3mhtreplyone",
|
||||
"cid": "bafyreicfake2",
|
||||
"author": {
|
||||
"did": "did:plc:replier1fake",
|
||||
"handle": "example.bsky.social",
|
||||
"displayName": "Reply Person One",
|
||||
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:replier1fake/bafkreifake2@jpeg"
|
||||
},
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Great point about the side benefits! The tanking argument always dominates but you're right that it's secondary.",
|
||||
"createdAt": "2026-03-24T21:55:00.000Z",
|
||||
"facets": []
|
||||
},
|
||||
"replyCount": 0,
|
||||
"repostCount": 1,
|
||||
"likeCount": 5,
|
||||
"indexedAt": "2026-03-24T21:55:01.000Z"
|
||||
},
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"$type": "app.bsky.feed.defs#threadViewPost",
|
||||
"post": {
|
||||
"uri": "at://did:plc:replier2fake/app.bsky.feed.post/3mhtreplytwo",
|
||||
"cid": "bafyreicfake3",
|
||||
"author": {
|
||||
"did": "did:plc:replier2fake",
|
||||
"handle": "hockeyfan.bsky.social",
|
||||
"displayName": "Hockey Fan",
|
||||
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:replier2fake/bafkreifake3@jpeg"
|
||||
},
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "The fan experience angle is so underrated. Late-season games for bad teams actually matter under Gold.",
|
||||
"createdAt": "2026-03-24T22:10:00.000Z",
|
||||
"facets": []
|
||||
},
|
||||
"replyCount": 0,
|
||||
"repostCount": 0,
|
||||
"likeCount": 3,
|
||||
"indexedAt": "2026-03-24T22:10:01.000Z"
|
||||
},
|
||||
"replies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
80
tests/stubs/freshrss_stubs.php
Normal file
80
tests/stubs/freshrss_stubs.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Minimal stubs for FreshRSS classes so the extension can be loaded and
|
||||
* tested outside of a full FreshRSS installation.
|
||||
*/
|
||||
|
||||
abstract class Minz_Extension {
|
||||
private array $userConfig = [];
|
||||
|
||||
public function init(): void {}
|
||||
|
||||
public function handleConfigureAction(): void {}
|
||||
|
||||
protected function registerHook(string $name, callable $callback): void {}
|
||||
|
||||
public function setUserConfigurationValue(string $key, mixed $value): void {
|
||||
$this->userConfig[$key] = $value;
|
||||
}
|
||||
|
||||
public function getUserConfigurationValue(string $key): mixed {
|
||||
return $this->userConfig[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class FreshRSS_Entry {
|
||||
private string $link = '';
|
||||
private string $content = '';
|
||||
private int $date = 0;
|
||||
|
||||
public function link(): string { return $this->link; }
|
||||
|
||||
/** @param string $link */
|
||||
public function _link(string $link): void { $this->link = $link; }
|
||||
|
||||
/** @param string $content */
|
||||
public function _content(string $content): void { $this->content = $content; }
|
||||
|
||||
public function content(): string { return $this->content; }
|
||||
|
||||
public function date(bool $asTimestamp = false): mixed {
|
||||
return $asTimestamp ? $this->date : date('r', $this->date);
|
||||
}
|
||||
|
||||
public function _date(int $timestamp): void { $this->date = $timestamp; }
|
||||
}
|
||||
|
||||
class FreshRSS_EntryDao {
|
||||
public function updateEntry(FreshRSS_Entry $entry): void {}
|
||||
}
|
||||
|
||||
class FreshRSS_Factory {
|
||||
public static function createEntryDao(): FreshRSS_EntryDao {
|
||||
return new FreshRSS_EntryDao();
|
||||
}
|
||||
}
|
||||
|
||||
class Minz_Request {
|
||||
private static bool $isPost = false;
|
||||
private static array $params = [];
|
||||
|
||||
public static function isPost(): bool { return self::$isPost; }
|
||||
|
||||
public static function paramString(string $key): string {
|
||||
return self::$params[$key] ?? '';
|
||||
}
|
||||
|
||||
/** Test helper: configure the fake request state. */
|
||||
public static function simulatePost(array $params): void {
|
||||
self::$isPost = true;
|
||||
self::$params = $params;
|
||||
}
|
||||
|
||||
public static function reset(): void {
|
||||
self::$isPost = false;
|
||||
self::$params = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user