Refactor feeds to be more testable and to remove templates. (#36)
Move feed generation into generator classes and out of templates. Remove feed templates, since they don't have any UI elements. Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/36 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
		
							parent
							
								
									bb58e09cbf
								
							
						
					
					
						commit
						dc63d70944
					
				| @ -1,5 +1,5 @@ | ||||
| name: Prerequisites Testing | ||||
| on: [push, pull_request] | ||||
| on: [pull_request] | ||||
| 
 | ||||
| jobs: | ||||
|   test-php-version-requirements: | ||||
|  | ||||
| @ -1,34 +1,27 @@ | ||||
| <?php | ||||
| class FeedController extends Controller { | ||||
|     private array $vars; | ||||
| 
 | ||||
|     protected function render(string $templateFile, array $vars = []) { | ||||
|         $templatePath = TEMPLATES_DIR . "/" . $templateFile; | ||||
| 
 | ||||
|         if (!file_exists($templatePath)) { | ||||
|             throw new RuntimeException("Template not found: $templatePath"); | ||||
|         } | ||||
| 
 | ||||
|         extract($vars, EXTR_SKIP); | ||||
|         include $templatePath; | ||||
|     } | ||||
|     private $config; | ||||
|     private $ticks; | ||||
| 
 | ||||
|     public function __construct(){ | ||||
|         $config = ConfigModel::load(); | ||||
|         $this->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(); | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										53
									
								
								src/Feed/AtomGenerator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/Feed/AtomGenerator.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| <?php | ||||
| class AtomGenerator extends FeedGenerator { | ||||
|     public function generate(): string { | ||||
|         $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\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(); | ||||
|         ?>
 | ||||
| <feed xmlns="http://www.w3.org/2005/Atom"> | ||||
|   <title><?php echo $feedTitle ?></title>
 | ||||
|   <link rel="self" | ||||
|         type="application/atom+xml" | ||||
|         title="<?php echo $feedTitle ?>" | ||||
|         href="<?php echo $feedUrl ?>" /> | ||||
|   <link rel="alternate" href="<?php echo $siteUrl  ?>"/> | ||||
|   <updated><?php echo $updated ?></updated>
 | ||||
|   <id><?php echo $siteUrl ?></id>
 | ||||
|   <author> | ||||
|         <name><?= Util::escape_xml($this->config->siteTitle) ?></name>
 | ||||
|   </author> | ||||
| <?php foreach ($this->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); | ||||
| ?>
 | ||||
|   <entry> | ||||
|     <title><?= $tickTitle ?></title>
 | ||||
|     <link href="<?= $tickUrl ?>"/> | ||||
|     <id><?= $tickUrl ?></id>
 | ||||
|     <updated><?= $tickTime ?></updated>
 | ||||
|     <content type="html"><?= $tickContent ?></content>
 | ||||
|   </entry> | ||||
| <?php endforeach; ?>
 | ||||
| </feed> | ||||
| <?php | ||||
|         return ob_get_clean(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/Feed/FeedGenerator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/Feed/FeedGenerator.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| <?php | ||||
| // Abstract base class for feeds.
 | ||||
| // Specific feeds (RSS, Atom, etc.) will inherit from this.
 | ||||
| // This will wrap the basic generator functionality.
 | ||||
| abstract class FeedGenerator { | ||||
|     protected $config; | ||||
|     protected $ticks; | ||||
| 
 | ||||
|     public function __construct(ConfigModel $config, array $ticks) { | ||||
|         $this->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); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								src/Feed/RssGenerator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/Feed/RssGenerator.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,47 @@ | ||||
| <?php | ||||
| class RssGenerator extends FeedGenerator { | ||||
|     public function generate(): string { | ||||
|         $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; | ||||
|         $xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n"; | ||||
|         $xml .= $this->buildChannel(); | ||||
|         $xml .= '</rss>' . "\n"; | ||||
|         return $xml; | ||||
|     } | ||||
| 
 | ||||
|     public function getContentType(): string { | ||||
|         return 'application/rss+xml; charset=utf-8'; | ||||
|     } | ||||
| 
 | ||||
|     private function buildChannel(): string { | ||||
|         ob_start(); | ||||
|         ?>
 | ||||
| <channel> | ||||
|     <title><?php echo Util::escape_xml($this->config->siteTitle . ' RSS Feed') ?></title>
 | ||||
|     <link><?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath))?></link>
 | ||||
|     <atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/rss'))?>" | ||||
|                rel="self" | ||||
|                type="application/rss+xml" /> | ||||
|     <description><?php echo Util::escape_xml($this->config->siteDescription) ?></description>
 | ||||
|     <language>en-us</language> | ||||
|     <lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
 | ||||
| <?php foreach ($this->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); | ||||
| ?>
 | ||||
|     <item> | ||||
|         <title><?php echo $tickTitle ?></title>
 | ||||
|         <link><?php echo $tickUrl; ?></link>
 | ||||
|         <description><?php echo $tickDescription; ?></description>
 | ||||
|         <pubDate><?php echo $tickDate; ?></pubDate>
 | ||||
|         <guid><?php echo $tickUrl; ?></guid>
 | ||||
|     </item> | ||||
| <?php endforeach; ?>
 | ||||
| </channel> | ||||
| <?php | ||||
|         return ob_get_clean(); | ||||
|     } | ||||
| } | ||||
| @ -33,14 +33,14 @@ class Router { | ||||
|             $controller = $routeHandler[1]; | ||||
|             $methods = $routeHandler[2] ?? ['GET']; | ||||
| 
 | ||||
|             Log::debug("Route: '{$routePattern}', Controller {$controller}, Methods: ". implode(',' , $methods)); | ||||
| 
 | ||||
|             # Only allow valid route and filename characters
 | ||||
|             # to prevent directory traversal and other attacks
 | ||||
|             $routePattern = preg_replace('/\{([^}]+)\}/', '([a-zA-Z0-9._-]+)', $routePattern); | ||||
|             $routePattern = '#^' . $routePattern . '$#'; | ||||
| 
 | ||||
|             if (preg_match($routePattern, $requestPath, $matches)) { | ||||
|                 Log::debug("Request path: '{$requestPath}', Controller {$controller}, Methods: ". implode(',' , $methods)); | ||||
| 
 | ||||
|                 if (in_array($requestMethod, $methods)){ | ||||
|                     // Save any path elements we're interested in
 | ||||
|                     // (but discard the match on the entire path)
 | ||||
|  | ||||
| @ -62,4 +62,21 @@ class Util { | ||||
|         } | ||||
|         return $diff->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; | ||||
|     } | ||||
| } | ||||
| @ -1,40 +0,0 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var array $ticks */ ?>
 | ||||
| <?php | ||||
| $feedTitle = Util::escape_xml("$config->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 '<?xml version="1.0" encoding="utf-8"?>' . "\n"; | ||||
| ?>
 | ||||
| <feed xmlns="http://www.w3.org/2005/Atom"> | ||||
|   <title><?php echo $feedTitle ?></title>
 | ||||
|   <link rel="self" | ||||
|         type="application/atom+xml" | ||||
|         title="<?php echo $feedTitle ?>" | ||||
|         href="<?php echo $feedUrl ?>" /> | ||||
|   <link rel="alternate" href="<?php echo $siteUrl  ?>"/> | ||||
|   <updated><?php echo $updated ?></updated>
 | ||||
|   <id><?php echo $siteUrl ?></id>
 | ||||
|   <author> | ||||
|         <name><?= Util::escape_xml($config->siteTitle) ?></name>
 | ||||
|   </author> | ||||
| <?php foreach ($ticks as $tick): | ||||
|     // build the tick entry components
 | ||||
|     $tickPath = "tick/" . $tick['id']; | ||||
|     $tickUrl = Util::escape_xml($siteUrl . $basePath . $tickPath); | ||||
|     $tickTime = date(DATE_ATOM, strtotime($tick['timestamp'])); | ||||
|     $tickTitle = Util::escape_xml($tick['tick']); | ||||
|     $tickContent = Util::linkify($tickTitle); | ||||
| ?>
 | ||||
|   <entry> | ||||
|     <title><?= $tickTitle ?></title>
 | ||||
|     <link href="<?= $tickUrl ?>"/> | ||||
|     <id><?= $tickUrl ?></id>
 | ||||
|     <updated><?= $tickTime ?></updated>
 | ||||
|     <content type="html"><?= $tickContent ?></content>
 | ||||
|   </entry> | ||||
| <?php endforeach; ?>
 | ||||
| </feed> | ||||
| @ -1,39 +0,0 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var array $ticks */ ?>
 | ||||
| <?php | ||||
| // Need to have a little php here because the starting xml tag
 | ||||
| // will mess up the PHP parser.
 | ||||
| // TODO - I think short php tags can be disabled to prevent that.
 | ||||
| 
 | ||||
| header('Content-Type: application/rss+xml; charset=utf-8'); | ||||
| echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; | ||||
| ?>
 | ||||
| <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||||
| <channel> | ||||
|     <title><?php echo Util::escape_xml($config->siteTitle . 'RSS Feed') ?></title>
 | ||||
|     <link><?php echo Util::escape_xml($config->baseUrl . $config->basePath)?></link>
 | ||||
|     <atom:link href="<?php echo Util::escape_xml($config->baseUrl . $config->basePath. 'feed/rss')?>" | ||||
|                rel="self" | ||||
|                type="application/rss+xml" /> | ||||
|     <description><?php echo Util::escape_xml($config->siteDescription) ?></description>
 | ||||
|     <language>en-us</language> | ||||
|     <lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
 | ||||
| <?php foreach ($ticks as $tick): | ||||
|     // build the tick entry components
 | ||||
|     //$tickPath = "tick/$year/$month/$day/$hour/$minute/$second";
 | ||||
|     $tickPath = "tick/" . $tick['id']; | ||||
|     $tickUrl = Util::escape_xml($config->baseUrl . $config->basePath . $tickPath); | ||||
|     $tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); | ||||
|     $tickTitle = Util::escape_xml($tick['tick']); | ||||
|     $tickDescription = Util::linkify($tickTitle); | ||||
| ?>
 | ||||
|     <item> | ||||
|         <title><?php echo $tickTitle ?></title>
 | ||||
|         <link><?php echo $tickUrl; ?></link>
 | ||||
|         <description><?php echo $tickDescription; ?></description>
 | ||||
|         <pubDate><?php echo $tickDate; ?></pubDate>
 | ||||
|         <guid><?php echo $tickUrl; ?></guid>
 | ||||
|     </item> | ||||
| <?php endforeach; ?>
 | ||||
| </channel> | ||||
| </rss> | ||||
							
								
								
									
										139
									
								
								tests/Feed/AtomGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								tests/Feed/AtomGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | ||||
| <?php | ||||
| use PHPUnit\Framework\TestCase; | ||||
| 
 | ||||
| class AtomGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $config = new ConfigModel(); | ||||
|         $config->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('<?xml version="1.0"', $xml); | ||||
|         $this->assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', $xml); | ||||
|         $this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml); | ||||
|         $this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml); | ||||
|         $this->assertStringContainsString('<link rel="self"', $xml); | ||||
|         $this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml); | ||||
|         $this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml); | ||||
|         $this->assertStringContainsString('<author>', $xml); | ||||
|         $this->assertStringContainsString('<name>Test Site</name>', $xml); | ||||
|         $this->assertStringContainsString('<entry>', $xml); | ||||
|         $this->assertStringContainsString('</entry>', $xml); | ||||
|         $this->assertStringEndsWith('</feed>' . "\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('<?xml version="1.0"', $xml); | ||||
|         $this->assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', $xml); | ||||
|         $this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml); | ||||
|         $this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml); | ||||
|         $this->assertStringContainsString('<link rel="self"', $xml); | ||||
|         $this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml); | ||||
|         $this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml); | ||||
|         $this->assertStringContainsString('<author>', $xml); | ||||
|         $this->assertStringContainsString('<name>Test Site</name>', $xml); | ||||
|         $this->assertStringEndsWith('</feed>' . "\n", $xml); | ||||
| 
 | ||||
|         // Test tick content
 | ||||
|         $this->assertStringNotContainsString('<entry>', $xml); | ||||
|         $this->assertStringNotContainsString('</entry>', $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: <tag> & "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('<tag>', $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'); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										114
									
								
								tests/Feed/FeedGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								tests/Feed/FeedGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,114 @@ | ||||
| <?php | ||||
| use PHPUnit\Framework\TestCase; | ||||
| 
 | ||||
| class FeedGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $config = new ConfigModel(); | ||||
|         $config->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 '<test>content</test>'; | ||||
|             } | ||||
| 
 | ||||
|             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('<test>content</test>', $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'"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										135
									
								
								tests/Feed/RssGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								tests/Feed/RssGeneratorTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,135 @@ | ||||
| <?php | ||||
| use PHPUnit\Framework\TestCase; | ||||
| 
 | ||||
| class RssGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $config = new ConfigModel(); | ||||
|         $config->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('<?xml version="1.0"', $xml); | ||||
|         $this->assertStringContainsString('<rss version="2.0"', $xml); | ||||
|         $this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml); | ||||
|         $this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml); | ||||
|         $this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml); | ||||
|         $this->assertStringContainsString('<channel>', $xml); | ||||
|         $this->assertStringContainsString('<item>', $xml); | ||||
|         $this->assertStringContainsString('</item>', $xml); | ||||
|         $this->assertStringContainsString('</channel>', $xml); | ||||
|         $this->assertStringEndsWith('</rss>' . "\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('<?xml version="1.0"', $xml); | ||||
|         $this->assertStringContainsString('<rss version="2.0"', $xml); | ||||
|         $this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml); | ||||
|         $this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml); | ||||
|         $this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml); | ||||
|         $this->assertStringContainsString('<channel>', $xml); | ||||
|         $this->assertStringContainsString('</channel>', $xml); | ||||
|         $this->assertStringEndsWith('</rss>' . "\n", $xml); | ||||
| 
 | ||||
|         // Test tick content
 | ||||
|         $this->assertStringNotContainsString('<item>', $xml); | ||||
|         $this->assertStringNotContainsString('</item>', $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: <tag> & "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('<tag>', $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'); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user