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
|
name: Prerequisites Testing
|
||||||
on: [push, pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-php-version-requirements:
|
test-php-version-requirements:
|
||||||
|
@ -1,34 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
class FeedController extends Controller {
|
class FeedController extends Controller {
|
||||||
private array $vars;
|
private $config;
|
||||||
|
private $ticks;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __construct(){
|
public function __construct(){
|
||||||
$config = ConfigModel::load();
|
$this->config = ConfigModel::load();
|
||||||
$tickModel = new TickModel();
|
$tickModel = new TickModel();
|
||||||
$ticks = iterator_to_array($tickModel->stream($config->itemsPerPage));
|
$this->ticks = iterator_to_array($tickModel->stream($this->config->itemsPerPage));
|
||||||
|
|
||||||
$this->vars = [
|
Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
|
||||||
'config' => $config,
|
|
||||||
'ticks' => $ticks,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rss(){
|
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(){
|
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];
|
$controller = $routeHandler[1];
|
||||||
$methods = $routeHandler[2] ?? ['GET'];
|
$methods = $routeHandler[2] ?? ['GET'];
|
||||||
|
|
||||||
Log::debug("Route: '{$routePattern}', Controller {$controller}, Methods: ". implode(',' , $methods));
|
|
||||||
|
|
||||||
# Only allow valid route and filename characters
|
# Only allow valid route and filename characters
|
||||||
# to prevent directory traversal and other attacks
|
# to prevent directory traversal and other attacks
|
||||||
$routePattern = preg_replace('/\{([^}]+)\}/', '([a-zA-Z0-9._-]+)', $routePattern);
|
$routePattern = preg_replace('/\{([^}]+)\}/', '([a-zA-Z0-9._-]+)', $routePattern);
|
||||||
$routePattern = '#^' . $routePattern . '$#';
|
$routePattern = '#^' . $routePattern . '$#';
|
||||||
|
|
||||||
if (preg_match($routePattern, $requestPath, $matches)) {
|
if (preg_match($routePattern, $requestPath, $matches)) {
|
||||||
|
Log::debug("Request path: '{$requestPath}', Controller {$controller}, Methods: ". implode(',' , $methods));
|
||||||
|
|
||||||
if (in_array($requestMethod, $methods)){
|
if (in_array($requestMethod, $methods)){
|
||||||
// Save any path elements we're interested in
|
// Save any path elements we're interested in
|
||||||
// (but discard the match on the entire path)
|
// (but discard the match on the entire path)
|
||||||
|
@ -62,4 +62,21 @@ class Util {
|
|||||||
}
|
}
|
||||||
return $diff->s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago';
|
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