Refactor feeds to be more testable and to remove templates. (#36)
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
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