diff --git a/.gitea/workflows/prerequisites.yaml b/.gitea/workflows/prerequisites.yaml index 45ae5b0..371eb9f 100644 --- a/.gitea/workflows/prerequisites.yaml +++ b/.gitea/workflows/prerequisites.yaml @@ -1,5 +1,5 @@ name: Prerequisites Testing -on: [push, pull_request] +on: [pull_request] jobs: test-php-version-requirements: diff --git a/src/Controller/FeedController/FeedController.php b/src/Controller/FeedController/FeedController.php index 5a9c998..a1e415b 100644 --- a/src/Controller/FeedController/FeedController.php +++ b/src/Controller/FeedController/FeedController.php @@ -1,34 +1,27 @@ config = ConfigModel::load(); $tickModel = new TickModel(); - $ticks = iterator_to_array($tickModel->stream($config->itemsPerPage)); + $this->ticks = iterator_to_array($tickModel->stream($this->config->itemsPerPage)); - $this->vars = [ - 'config' => $config, - 'ticks' => $ticks, - ]; + Log::debug("Loaded " . count($this->ticks) . " ticks for feeds"); } public function rss(){ - $this->render("feed/rss.php", $this->vars); + $generator = new RssGenerator($this->config, $this->ticks); + + header('Content-Type: ' . $generator->getContentType()); + echo $generator->generate(); } public function atom(){ - $this->render("feed/atom.php", $this->vars); + $generator = new AtomGenerator($this->config, $this->ticks); + + header('Content-Type: ' . $generator->getContentType()); + echo $generator->generate(); } } diff --git a/src/Feed/AtomGenerator.php b/src/Feed/AtomGenerator.php new file mode 100644 index 0000000..8411ad2 --- /dev/null +++ b/src/Feed/AtomGenerator.php @@ -0,0 +1,53 @@ +' . "\n"; + $xml .= $this->buildFeed(); + return $xml; + } + + public function getContentType(): string { + return 'application/atom+xml; charset=utf-8'; + } + + private function buildFeed(): string { + $feedTitle = Util::escape_xml($this->config->siteTitle . " Atom Feed"); + $siteUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath)); + $feedUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/atom')); + $updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now')); + + ob_start(); + ?> + + <?php echo $feedTitle ?> + + + + + + config->siteTitle) ?> + +ticks as $tick): + // build the tick entry components + $tickPath = "tick/" . $tick['id']; + $tickUrl = Util::escape_xml($siteUrl . $tickPath); + $tickTime = date(DATE_ATOM, strtotime($tick['timestamp'])); + $tickTitle = Util::escape_xml($tick['tick']); + $tickContent = Util::linkify($tickTitle); +?> + + <?= $tickTitle ?> + + + + + + + +config = $config; + $this->ticks = $ticks; + } + + abstract public function generate(): string; + abstract public function getContentType(): string; + + protected function buildTickUrl(int $tickId): string { + return Util::buildUrl($this->config->baseUrl, $this->config->basePath, "tick/{$tickId}"); + } + + protected function getSiteUrl(): string { + return Util::buildUrl($this->config->baseUrl, $this->config->basePath); + } +} \ No newline at end of file diff --git a/src/Feed/RssGenerator.php b/src/Feed/RssGenerator.php new file mode 100644 index 0000000..3a7e677 --- /dev/null +++ b/src/Feed/RssGenerator.php @@ -0,0 +1,47 @@ +' . "\n"; + $xml .= '' . "\n"; + $xml .= $this->buildChannel(); + $xml .= '' . "\n"; + return $xml; + } + + public function getContentType(): string { + return 'application/rss+xml; charset=utf-8'; + } + + private function buildChannel(): string { + ob_start(); + ?> + + <?php echo Util::escape_xml($this->config->siteTitle . ' RSS Feed') ?> + config->baseUrl, $this->config->basePath))?> + + config->siteDescription) ?> + en-us + +ticks as $tick): + // build the tick entry components + $tickPath = "tick/" . $tick['id']; + $tickUrl = Util::escape_xml($this->buildTickUrl($tick['id'])); + $tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); + $tickTitle = Util::escape_xml($tick['tick']); + $tickDescription = Util::linkify($tickTitle); +?> + + <?php echo $tickTitle ?> + + + + + + + +s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago'; } + + public static function buildUrl(string $baseUrl, string $basePath, string $path = ''): string { + // Normalize baseUrl (remove trailing slash) + $baseUrl = rtrim($baseUrl, '/'); + + // Normalize basePath (ensure leading slash, remove trailing slash unless it's just '/') + if ($basePath === '' || $basePath === '/') { + $basePath = '/'; + } else { + $basePath = '/' . trim($basePath, '/') . '/'; + } + + // Normalize path (remove leading slash if present) + $path = ltrim($path, '/'); + + return $baseUrl . $basePath . $path; + } } \ No newline at end of file diff --git a/templates/feed/atom.php b/templates/feed/atom.php deleted file mode 100644 index a00936b..0000000 --- a/templates/feed/atom.php +++ /dev/null @@ -1,40 +0,0 @@ - - -siteTitle Atom Feed"); -$siteUrl = Util::escape_xml($config->baseUrl . $config->basePath); -$feedUrl = Util::escape_xml($config->baseUrl . $config->basePath . 'feed/atom'); -$updated = date(DATE_ATOM, strtotime($ticks[0]['timestamp'] ?? 'now')); - -header('Content-Type: application/atom+xml; charset=utf-8'); -echo '' . "\n"; -?> - - <?php echo $feedTitle ?> - - - - - - siteTitle) ?> - - - - <?= $tickTitle ?> - - - - - - - diff --git a/templates/feed/rss.php b/templates/feed/rss.php deleted file mode 100644 index 59c037e..0000000 --- a/templates/feed/rss.php +++ /dev/null @@ -1,39 +0,0 @@ - - -' . "\n"; -?> - - - <?php echo Util::escape_xml($config->siteTitle . 'RSS Feed') ?> - baseUrl . $config->basePath)?> - - siteDescription) ?> - en-us - -baseUrl . $config->basePath . $tickPath); - $tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); - $tickTitle = Util::escape_xml($tick['tick']); - $tickDescription = Util::linkify($tickTitle); -?> - - <?php echo $tickTitle ?> - - - - - - - - diff --git a/tests/Feed/AtomGeneratorTest.php b/tests/Feed/AtomGeneratorTest.php new file mode 100644 index 0000000..fe74773 --- /dev/null +++ b/tests/Feed/AtomGeneratorTest.php @@ -0,0 +1,139 @@ +siteTitle = 'Test Site'; + $config->siteDescription = 'Test Description'; + $config->baseUrl = 'https://example.com'; + $config->basePath = '/tkr/'; + return $config; + } + + private function createSampleTicks() { + return [ + ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'], + ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick'] + ]; + } + + public function testCanGenerateValidAtom() { + $config = $this->createMockConfig(); + $ticks = $this->createSampleTicks(); + + $generator = new AtomGenerator($config, $ticks); + $xml = $generator->generate(); + + // Test XML structure + $this->assertStringStartsWith('assertStringContainsString('', $xml); + $this->assertStringContainsString('Test Site Atom Feed', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml); + $this->assertStringContainsString('https://example.com/tkr/', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('Test Site', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringEndsWith('' . "\n", $xml); + + // Test tick content + $this->assertStringContainsString('First test tick', $xml); + $this->assertStringContainsString('Second test tick', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'Valid Atom should load into an XML document'); + } + + public function testReturnsCorrectContentType() { + $generator = new AtomGenerator($this->createMockConfig(), []); + $this->assertEquals('application/atom+xml; charset=utf-8', $generator->getContentType()); + } + + public function testCanHandleEmptyTickList() { + $config = $this->createMockConfig(); + $generator = new AtomGenerator($config, []); + $xml = $generator->generate(); + + // Should still be valid Atom with no entries + // Test XML structure + $this->assertStringStartsWith('assertStringContainsString('', $xml); + $this->assertStringContainsString('Test Site Atom Feed', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml); + $this->assertStringContainsString('https://example.com/tkr/', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('Test Site', $xml); + $this->assertStringEndsWith('' . "\n", $xml); + + // Test tick content + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'XML with no entries should still be valid'); + } + + public function testCanHandleSpecialCharactersAndUnicode() { + $config = $this->createMockConfig(); + + // Test various challenging characters + $ticks = [ + [ + 'id' => 1, + 'timestamp' => '2025-01-15 12:00:00', + 'tick' => 'Testing emojis 🎉🔥💯 and unicode characters' + ], + [ + 'id' => 2, + 'timestamp' => '2025-01-15 13:00:00', + 'tick' => 'XML entities: & "quotes" & \'apostrophes\'' + ], + [ + 'id' => 3, + 'timestamp' => '2025-01-15 14:00:00', + 'tick' => 'International: café naïve résumé 北京 москва' + ], + [ + 'id' => 4, + 'timestamp' => '2025-01-15 15:00:00', + 'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥' + ] + ]; + + $generator = new AtomGenerator($config, $ticks); + $xml = $generator->generate(); + + // Test that emojis are preserved + $this->assertStringContainsString('🎉🔥💯', $xml); + + // Test that XML entities are properly escaped + $this->assertStringContainsString('<tag>', $xml); + $this->assertStringContainsString('&', $xml); + $this->assertStringContainsString('"quotes"', $xml); + $this->assertStringContainsString(''apostrophes'', $xml); + + // Test that international characters are preserved + $this->assertStringContainsString('café naïve résumé', $xml); + $this->assertStringContainsString('北京', $xml); + $this->assertStringContainsString('москва', $xml); + + // Test that math symbols are preserved + $this->assertStringContainsString('∑ ∆ π ∞', $xml); + + // Ensure no raw < > & characters (security) + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString(' & "', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid'); + } +} \ No newline at end of file diff --git a/tests/Feed/FeedGeneratorTest.php b/tests/Feed/FeedGeneratorTest.php new file mode 100644 index 0000000..677dfa0 --- /dev/null +++ b/tests/Feed/FeedGeneratorTest.php @@ -0,0 +1,114 @@ +siteTitle = 'Test Site'; + $config->siteDescription = 'Test Description'; + $config->baseUrl = 'https://example.com'; + $config->basePath = '/tkr/'; + return $config; + } + + private function createSampleTicks() { + return [ + ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'], + ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick'] + ]; + } + + private function createTestGenerator($config = null, $ticks = null) { + $config = $config ?? $this->createMockConfig(); + $ticks = $ticks ?? $this->createSampleTicks(); + + return new class($config, $ticks) extends FeedGenerator { + public function generate(): string { + return 'content'; + } + + public function getContentType(): string { + return 'application/test+xml'; + } + + // Expose protected methods for testing + public function testBuildTickUrl(int $tickId): string { + return $this->buildTickUrl($tickId); + } + + public function testGetSiteUrl(): string { + return $this->getSiteUrl(); + } + }; + } + + public function testConstructorStoresConfigAndTicks() { + $generator = $this->createTestGenerator(); + + $this->assertEquals('content', $generator->generate()); + $this->assertEquals('application/test+xml', $generator->getContentType()); + } + + public function testBuildTickUrlGeneratesCorrectUrl() { + $generator = $this->createTestGenerator(); + + $tickUrl = $generator->testBuildTickUrl(123); + $this->assertEquals('https://example.com/tkr/tick/123', $tickUrl); + } + + public function testGetSiteUrlGeneratesCorrectUrl() { + $generator = $this->createTestGenerator(); + + $siteUrl = $generator->testGetSiteUrl(); + $this->assertEquals('https://example.com/tkr/', $siteUrl); + } + + public function testUrlMethodsHandleSubdomainConfiguration() { + $config = new ConfigModel(); + $config->siteTitle = 'Test Site'; + $config->baseUrl = 'https://tkr.example.com'; + $config->basePath = '/'; + + $generator = $this->createTestGenerator($config, []); + + $this->assertEquals('https://tkr.example.com/', $generator->testGetSiteUrl()); + $this->assertEquals('https://tkr.example.com/tick/456', $generator->testBuildTickUrl(456)); + } + + public function testUrlMethodsHandleEmptyBasePath() { + $config = new ConfigModel(); + $config->siteTitle = 'Test Site'; + $config->baseUrl = 'https://example.com'; + $config->basePath = ''; + + $generator = $this->createTestGenerator($config, []); + + $this->assertEquals('https://example.com/', $generator->testGetSiteUrl()); + $this->assertEquals('https://example.com/tick/789', $generator->testBuildTickUrl(789)); + } + + public function testUrlMethodsHandleVariousBasePathFormats() { + $testCases = [ + // [basePath, expectedSiteUrl, expectedTickUrl] + ['', 'https://example.com/', 'https://example.com/tick/123'], + ['/', 'https://example.com/', 'https://example.com/tick/123'], + ['tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'], + ['/tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'], + ['tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'], + ['/tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'], + ]; + + foreach ($testCases as [$basePath, $expectedSiteUrl, $expectedTickUrl]) { + $config = new ConfigModel(); + $config->siteTitle = 'Test Site'; + $config->baseUrl = 'https://example.com'; + $config->basePath = $basePath; + + $generator = $this->createTestGenerator($config, []); + + $this->assertEquals($expectedSiteUrl, $generator->testGetSiteUrl(), "Failed for basePath: '$basePath'"); + $this->assertEquals($expectedTickUrl, $generator->testBuildTickUrl(123), "Failed for basePath: '$basePath'"); + } + } +} \ No newline at end of file diff --git a/tests/Feed/RssGeneratorTest.php b/tests/Feed/RssGeneratorTest.php new file mode 100644 index 0000000..7405d45 --- /dev/null +++ b/tests/Feed/RssGeneratorTest.php @@ -0,0 +1,135 @@ +siteTitle = 'Test Site'; + $config->siteDescription = 'Test Description'; + $config->baseUrl = 'https://example.com'; + $config->basePath = '/tkr/'; + return $config; + } + + private function createSampleTicks() { + return [ + ['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'], + ['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick'] + ]; + } + + public function testCanGenerateValidRss() { + $config = $this->createMockConfig(); + $ticks = $this->createSampleTicks(); + + $generator = new RssGenerator($config, $ticks); + $xml = $generator->generate(); + + // Test XML structure + $this->assertStringStartsWith('assertStringContainsString('assertStringContainsString('Test Site RSS Feed', $xml); + $this->assertStringContainsString('https://example.com/tkr/', $xml); + $this->assertStringContainsString('assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringEndsWith('' . "\n", $xml); + + // Test tick content + $this->assertStringContainsString('First test tick', $xml); + $this->assertStringContainsString('Second test tick', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'Valid RSS should load into an XML document'); + } + + public function testReturnsCorrectContentType() { + $generator = new RssGenerator($this->createMockConfig(), []); + $this->assertEquals('application/rss+xml; charset=utf-8', $generator->getContentType()); + } + + public function testCanHandleEmptyTickList() { + $config = $this->createMockConfig(); + $generator = new RssGenerator($config, []); + $xml = $generator->generate(); + + // Should still be valid RSS with no items + // Test XML structure + $this->assertStringStartsWith('assertStringContainsString('assertStringContainsString('Test Site RSS Feed', $xml); + $this->assertStringContainsString('https://example.com/tkr/', $xml); + $this->assertStringContainsString('assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringEndsWith('' . "\n", $xml); + + // Test tick content + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'XML with no items should still be valid'); + } + + public function testCanHandleSpecialCharactersAndUnicode() { + $config = $this->createMockConfig(); + + // Test various challenging characters + $ticks = [ + [ + 'id' => 1, + 'timestamp' => '2025-01-15 12:00:00', + 'tick' => 'Testing emojis 🎉🔥💯 and unicode characters' + ], + [ + 'id' => 2, + 'timestamp' => '2025-01-15 13:00:00', + 'tick' => 'XML entities: & "quotes" & \'apostrophes\'' + ], + [ + 'id' => 3, + 'timestamp' => '2025-01-15 14:00:00', + 'tick' => 'International: café naïve résumé 北京 москва' + ], + [ + 'id' => 4, + 'timestamp' => '2025-01-15 15:00:00', + 'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥' + ] + ]; + + $generator = new RssGenerator($config, $ticks); + $xml = $generator->generate(); + + // Test that emojis are preserved + $this->assertStringContainsString('🎉🔥💯', $xml); + + // Test that XML entities are properly escaped + $this->assertStringContainsString('<tag>', $xml); + $this->assertStringContainsString('&', $xml); + $this->assertStringContainsString('"quotes"', $xml); + $this->assertStringContainsString(''apostrophes'', $xml); + + // Test that international characters are preserved + $this->assertStringContainsString('café naïve résumé', $xml); + $this->assertStringContainsString('北京', $xml); + $this->assertStringContainsString('москва', $xml); + + // Test that math symbols are preserved + $this->assertStringContainsString('∑ ∆ π ∞', $xml); + + // Ensure no raw < > & characters (security) + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString(' & "', $xml); + + // Ensure the XML is still valid + $doc = new DOMDocument(); + $this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid'); + } +}