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

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:
Greg Sarjeant 2025-07-31 00:05:46 +00:00 committed by greg
parent bb58e09cbf
commit dc63d70944
12 changed files with 545 additions and 102 deletions

View File

@ -1,5 +1,5 @@
name: Prerequisites Testing
on: [push, pull_request]
on: [pull_request]
jobs:
test-php-version-requirements:

View File

@ -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();
}
}

View 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();
}
}

View 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
View 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();
}
}

View File

@ -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)

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>

View 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('&lt;tag&gt;', $xml);
$this->assertStringContainsString('&amp;', $xml);
$this->assertStringContainsString('&quot;quotes&quot;', $xml);
$this->assertStringContainsString('&apos;apostrophes&apos;', $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');
}
}

View 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'");
}
}
}

View 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('&lt;tag&gt;', $xml);
$this->assertStringContainsString('&amp;', $xml);
$this->assertStringContainsString('&quot;quotes&quot;', $xml);
$this->assertStringContainsString('&apos;apostrophes&apos;', $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');
}
}