File: /home/xedaptot/ai.naniguide.com/tests/Unit/HtmlHandlersTest.php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use League\Pipeline\PipelineBuilder;
use App\Library\HtmlHandler\ParseRss;
use App\Library\HtmlHandler\ReplaceBareLineFeed;
use App\Library\HtmlHandler\AppendHtml;
use App\Library\HtmlHandler\TransformTag;
use App\Library\HtmlHandler\InjectTrackingPixel;
use App\Library\HtmlHandler\MakeInlineCss;
use App\Library\HtmlHandler\TransformUrl;
use App\Library\HtmlHandler\AddDoctype;
use App\Library\HtmlHandler\RemoveTitleTag;
use App\Library\HtmlHandler\AddPreheader;
use App\Library\HtmlHandler\GenerateSpintax;
use App\Library\HtmlHandler\InjectMessageIdToBody;
use App\Model\Subscriber;
use App\Model\Campaign;
use App\Model\Email;
use App\Model\MailList;
use App\Model\Template;
use App\Model\TrackingDomain;
use App\Model\Customer;
use Mockery;
use Exception;
use DOMDocument;
use App\Library\StringHelper;
use App\Services\UrlService;
use App\Library\Contracts\TagResolverInterface;
use App\Library\GeneralTagResolver;
use Carbon\Carbon;
class HtmlHandlersTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->service = new UrlService();
// Set base URL
config()->set('app.url', 'https://example.com');
// @important: route("named route") does not respect config(app.url)
// it uses the actual request url or localhost (in console)
// So, force it in test
$this->app['url']->forceRootUrl(config('app.url'));
}
public function test_parse_rss()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new ParseRss());
$html = "{% set rss = rss('http://rss.cnn.com/rss/edition.rss', 5) %}
{% for post in rss.item %}
<h3>{{ post.title }}</h3>
<p>{{ post.description | raw }}</p>
<hr>
{% endfor %}";
$out = $pipeline->build()->process($html);
$this->assertTrue(strpos($out, '{% for post in rss.item %}') === false);
$this->assertTrue(strpos($out, '<h3>{{ post.title }}</h3>') === false);
}
public function test_replace_bare_line_feed()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new ReplaceBareLineFeed());
$html = "Hello\nWorld\nFrom\r\nLaravel\n";
$out = $pipeline->build()->process($html);
// Notice that message should end with an "\r\n" character as suggested by RFC2822
$this->assertEquals($out, "Hello\r\nWorld\r\nFrom\r\nLaravel\r\n");
}
public function test_append_html()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new AppendHtml('<a>click me</a>'));
$pipeline->add(new AppendHtml('<p>read me</p>'));
$html = "<html><body><div>Content main</div></body></html>";
$out = $pipeline->build()->process($html);
$this->assertEquals($out, '<html><body><div>Content main</div><a>click me</a><p>read me</p></body></html>');
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Cannot find <body> tag to append to");
// without a Body tag to append to
$html = "<div>Content main</div>";
$out = $pipeline->build()->process($html);
}
public function test_add_preheader()
{
$preheader = 'Hello world';
$pipeline = new PipelineBuilder();
$pipeline->add(new AddPreheader('Hello world'));
$html = "<html><body><div>Content main</div></body></html>";
$out = $pipeline->build()->process($html);
$this->assertEquals($out, "<html><body><div style=\"display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden\">{$preheader}</div><div>Content main</div></body></html>");
$this->expectException(\Exception::class);
$this->expectExceptionMessage('There is no <BODY> tag');
// No <body> to add preheader to
$html = "<div>Content main</div>";
$out = $pipeline->build()->process($html);
}
public function test_transform_tag()
{
// Create a mock that implements the interface
$campaign = Mockery::mock(TagResolverInterface::class);
$campaign->shouldReceive('getResolvedTags')
->andReturn(['campaign' => ['name' => 'My Campaign']]);
// Similarly for subscriber
$subscriber = Mockery::mock(TagResolverInterface::class);
$subscriber->shouldReceive('getResolvedTags')
->andReturn(['subscriber' => ['uid' => '000001']]);
// Pipe
$pipeline = new PipelineBuilder();
$pipeline->add(new TransformTag($campaign, $subscriber, new GeneralTagResolver()));
$pipeline->add(new AppendHtml('<a>click me</a>'));
$pipeline->add(new AppendHtml('<p>read me</p>'));
$html = "<html><body><div>Campaign: '{{CAMPAIGN_NAME}}' Subscriber: '{{SUBSCRIBER_UID}}'</div></body></html>";
$out = $pipeline->build()->process($html);
$this->assertEquals($out, "<html><body><div>Campaign: 'My Campaign' Subscriber: '000001'</div><a>click me</a><p>read me</p></body></html>");
}
public function test_transform_tag_with_general_resolver()
{
// Freeze time so the assertion is deterministic.
Carbon::setTestNow(Carbon::create(2026, 4, 18, 9, 30, 45));
try {
$resolver = new GeneralTagResolver();
$pipeline = new PipelineBuilder();
$pipeline->add(new TransformTag($resolver));
// Mix lowercase + UPPERCASE + nested dot-notation access.
$html = '<p>Year: {{CURRENT_YEAR}} | Month: {{current_month}} ({{CURRENT_MONTH_NAME}}) '
. '| Day: {{current_day}} ({{current_weekday}}) '
. '| Date: {{CURRENT_DATE}} | Time: {{current_time}} | DT: {{CURRENT_DATETIME}}</p>';
$out = $pipeline->build()->process($html);
$this->assertEquals(
'<p>Year: 2026 | Month: 04 (April) | Day: 18 (Saturday) '
. '| Date: 2026-04-18 | Time: 09:30:45 | DT: 2026-04-18 09:30:45</p>',
$out
);
} finally {
Carbon::setTestNow();
}
}
public function test_inject_tracking_pixel()
{
$campaign = Mockery::mock(new Campaign(['name' => 'Campaign']));
$pipeline = new PipelineBuilder();
$pipeline->add(new InjectTrackingPixel($campaign, 'MSGID'));
$html = "<html><body><div>Content main</div></body></html>";
$out = $pipeline->build()->process($html);
// Something like: https://localhost/p/TVNHSUQ/open
// With StringHelper::base64UrlEncode('MSGID') ==> "TVNHSUQ"
$openTrackingUrl = route('openTrackingUrl', ['message_id' => StringHelper::base64UrlEncode('MSGID')], true);
$this->assertEquals($out, '<html><body><div>Content main</div><img alt="This is a tracking pixel" src="'.$openTrackingUrl.'" width="0" height="0" style="visibility:hidden"></body></html>');
}
public function test_make_inline_css()
{
$campaign = Mockery::mock(new Campaign(['name' => 'Campaign']));
$pipeline = new PipelineBuilder();
$pipeline->add(new MakeInlineCss([storage_path('tests/sample.css')]));
$html = "<html><body><div class='main'><a>Content main</a><span class='big blue '>Test<span></div></body></html>";
$out = $pipeline->build()->process($html);
$this->assertEquals($out, '<!DOCTYPE html><html><body><div class="main"><a style="color:green">Content main</a><span class="big blue " style="font-weight:600;color:blue">Test<span></span></span></div></body></html>');
}
public function test_test_campaign_html_content()
{
// Freeze time so GeneralTagResolver output is deterministic.
Carbon::setTestNow(Carbon::create(2026, 4, 18, 9, 30, 45));
try {
// List
$list = new MailList();//real
$list->uid = 'L00001';
$campaign = new Campaign;
$campaign->name = 'My Campaign';
// Similarly for subscriber
$subscriber = Mockery::mock(TagResolverInterface::class);
$subscriber->shouldReceive('getResolvedTags')
->andReturn(['subscriber' => ['uid' => 'S00001']]);
$pipeline = new PipelineBuilder();
$pipeline->add(new AddDoctype());
$pipeline->add(new RemoveTitleTag());
$pipeline->add(new ReplaceBareLineFeed());
$pipeline->add(new AppendHtml('<div>Hello world éèéêôâ</div>'));
//$pipeline->add(new ParseRss());
$pipeline->add(new TransformTag($campaign, $subscriber, $list, new GeneralTagResolver()));
$pipeline->add(new TransformUrl('MSGID', $trackingDomain = null));
$pipeline->add(new MakeInlineCss([storage_path('tests/sample.css')]));
$pipeline->add(new InjectTrackingPixel($campaign, 'MSGID'));
$pipeline->add(new AddDoctype());
$html = $pipeline->build()->process('<title class="blue">\nThisis the template title</title><a class=" big blue" href="https://mailchimp.com"></a><div>Campaign: {{CAMPAIGN_NAME}} Subscriber: {{subscriber_uid}}</div> List {{list.uid}} <div>© {{CURRENT_YEAR}} — sent {{CURRENT_MONTH_NAME}} {{current_day}}, {{current_date}} at {{current_time}}</div>');
$expectedResult = '<!DOCTYPE html><html><head></head><body><a class=" big blue" href="http://example.com/p/click?url=aHR0cHM6Ly9tYWlsY2hpbXAuY29t&message_id=TVNHSUQ" style="font-weight:600;color:blue"></a><div>Campaign: My Campaign Subscriber: S00001</div> List L00001 <div>© 2026 — sent April 18, 2026-04-18 at 09:30:45</div><div>Hello world éèéêôâ</div><img alt="This is a tracking pixel" src="http://example.com/p/TVNHSUQ/open" width="0" height="0" style="visibility:hidden"></body></html>';
$this->assertEquals($html, $expectedResult);
} finally {
Carbon::setTestNow();
}
}
public function test_test_campaign_html_with_types_of_urls()
{
$messageId = 'MSGID';
$campaign = new Campaign;
$campaign->name = 'My Campaign';
// Similarly for subscriber
$subscriber = Mockery::mock(Subscriber::class);
$subscriber->shouldReceive('generateUpdateProfileUrl')
->andReturn('http://example.com/update-profile');
$subscriber->shouldReceive('generateUnsubscribeFormUrl')
->andReturn('http://example.com/unsubscribe');
$subscriber->shouldReceive('getResolvedTags')
->andReturn(['subscriber' => ['uid' => 'S00001']]);
$subscriberResolver = \App\Library\AdvancedSubscriberTagResolver::for($subscriber, $messageId);
$pipeline = new PipelineBuilder();
$pipeline->add(new AddDoctype());
$pipeline->add(new RemoveTitleTag());
$pipeline->add(new ReplaceBareLineFeed());
$pipeline->add(new AppendHtml('<div>test</div>'));
//$pipeline->add(new ParseRss());
$pipeline->add(new TransformTag($campaign, $subscriberResolver, new GeneralTagResolver()));
$pipeline->add(new TransformUrl($messageId, $trackingDomain = null, $trackClick = true));
$pipeline->add(new MakeInlineCss([storage_path('tests/sample.css')]));
$pipeline->add(new InjectTrackingPixel($campaign, $messageId));
$pipeline->add(new AddDoctype());
$html1 = $pipeline->build()->process("<a href='http://other.site'>public link</a><img src='//cdn.com/img.jpg' title='public cdn'><a href='#ankor'>jump to</a><a href='//yet-another.com'>Protocol relative</a><a href='mailto:[email protected]'>resource url</a><a href='{{unsubscribe_url}}'>Opt-out</a><a href='{{update_profile_url}}'>Update subscription</a>Web view: {{web_view_url}}<img src='/my/avatar.jpg'>");
$expectedResult1 = '<!DOCTYPE html><html><body><a href="http://example.com/p/click?url=aHR0cDovL290aGVyLnNpdGU&message_id=TVNHSUQ">public link</a><img src="//cdn.com/img.jpg" title="public cdn"><a href="#ankor">jump to</a><a href="http://example.com/p/click?url=Ly95ZXQtYW5vdGhlci5jb20&message_id=TVNHSUQ">Protocol relative</a><a href="mailto:[email protected]">resource url</a><a href="http://example.com/p/click?url=aHR0cDovL2V4YW1wbGUuY29tL3Vuc3Vic2NyaWJl&message_id=TVNHSUQ">Opt-out</a><a href="http://example.com/p/click?url=aHR0cDovL2V4YW1wbGUuY29tL3VwZGF0ZS1wcm9maWxl&message_id=TVNHSUQ">Update subscription</a>Web view: http://example.com/campaigns/TVNHSUQ/web-view<img src="https://example.com/my/avatar.jpg"><div>test</div><img alt="This is a tracking pixel" src="http://example.com/p/TVNHSUQ/open" width="0" height="0" style="visibility:hidden"></body></html>';
// Execute StringHelper::base64UrlDecode($base65Url) to get the unsubscribe and update profile URLs
$this->assertEquals($html1, $expectedResult1);
// Tracking domain
$domain = new TrackingDomain();
$domain->name = 'track.me.io';
$domain->scheme = 'https';
$domain->verification_method = TrackingDomain::VERIFICATION_METHOD_HOST;
$pipeline = new PipelineBuilder();
$pipeline->add(new AddDoctype());
$pipeline->add(new RemoveTitleTag());
$pipeline->add(new ReplaceBareLineFeed());
$pipeline->add(new AppendHtml('<div>test</div>'));
//$pipeline->add(new ParseRss());
$pipeline->add(new TransformTag($campaign, $subscriberResolver, new GeneralTagResolver()));
$pipeline->add(new TransformUrl($messageId, $domain, $trackClick = true));
$pipeline->add(new MakeInlineCss([storage_path('tests/sample.css')]));
$pipeline->add(new InjectTrackingPixel($campaign, $messageId));
$pipeline->add(new AddDoctype());
$html2 = $pipeline->build()->process("<a href='http://other.site'>public link</a><img src='//cdn.com/img.jpg' title='public cdn'><a href='#ankor'>jump to</a><a href='//yet-another.com'>Protocol relative</a><a href='mailto:[email protected]'>resource url</a><a href='{{unsubscribe_url}}'>Opt-out</a><a href='{{update_profile_url}}'>Update subscription</a>Web view: {{web_view_url}}<img src='/my/avatar.jpg'>");
$expectedResult2 = '<!DOCTYPE html><html><body><a href="https://track.me.io/?t=aHR0cDovL2V4YW1wbGUuY29tL3AvY2xpY2s_dXJsPWFIUjBjRG92TDI5MGFHVnlMbk5wZEdVJm1lc3NhZ2VfaWQ9VFZOSFNVUQ">public link</a><img src="https://track.me.io/?t=Ly9jZG4uY29tL2ltZy5qcGc" title="public cdn"><a href="#ankor">jump to</a><a href="https://track.me.io/?t=aHR0cDovL2V4YW1wbGUuY29tL3AvY2xpY2s_dXJsPUx5OTVaWFF0WVc1dmRHaGxjaTVqYjIwJm1lc3NhZ2VfaWQ9VFZOSFNVUQ">Protocol relative</a><a href="mailto:[email protected]">resource url</a><a href="https://track.me.io/?t=aHR0cDovL2V4YW1wbGUuY29tL3AvY2xpY2s_dXJsPWFIUjBjRG92TDJWNFlXMXdiR1V1WTI5dEwzVnVjM1ZpYzJOeWFXSmwmbWVzc2FnZV9pZD1UVk5IU1VR">Opt-out</a><a href="https://track.me.io/?t=aHR0cDovL2V4YW1wbGUuY29tL3AvY2xpY2s_dXJsPWFIUjBjRG92TDJWNFlXMXdiR1V1WTI5dEwzVndaR0YwWlMxd2NtOW1hV3hsJm1lc3NhZ2VfaWQ9VFZOSFNVUQ">Update subscription</a>Web view: http://example.com/campaigns/TVNHSUQ/web-view<img src="https://track.me.io/?t=aHR0cHM6Ly9leGFtcGxlLmNvbS9teS9hdmF0YXIuanBn"><div>test</div><img alt="This is a tracking pixel" src="http://example.com/p/TVNHSUQ/open" width="0" height="0" style="visibility:hidden"></body></html>';
// There is a small problem here: tracking pixel does not apply tracking domain
// Execute StringHelper::base64UrlDecode($base65Url) to get the unsubscribe and update profile URLs
$this->assertEquals($html2, $expectedResult2);
}
public function test_caching_just_works()
{
// Template
$template = Mockery::mock(new Template(['name' => 'Template', 'content' => 'Original']));
$template->generateUid();
// List
$list = Mockery::mock(new MailList());
$list->generateUid();
// Campaign
$campaign = Mockery::mock(new Campaign(['name' => 'Campaign']));
$campaign->uid = 'mocked-uid';
$campaign->defaultMailList = $list;
// Email (content layer) — holds the template
$email = new Email(['uid' => 'mocked-email-uid']);
$email->setRelation('template', $template);
$campaign->setRelation('email', $email);
$campaign->email_id = 1; // enable EMAIL_ATTRIBUTES delegation
// Flush any cache
$campaign->clearTemplateCache();
// Get
$html = $campaign->getHtmlContent();
$cachedHtml = $campaign->getHtmlContent($subscriber = null, $messageId = null, $server = null, $fromCache = true, $seconds = 1);
// Notice that message should end with an "\r\n" character as suggested by RFC2822
$this->assertEquals($html, '<!DOCTYPE html><html><body><p>Original</p><img src="'.asset('/images/transparent.gif').'" data="X-Client-Message-Id: [ null ]" alt="X-Client-Message-Id: [ null ]" width="0" height="0" style="visibility:hidden"></body></html>'."\r\n");
$this->assertEquals($html, $cachedHtml);
$template->content = 'Updated';
$html = $campaign->getHtmlContent();
$cachedHtml = $campaign->getHtmlContent($subscriber = null, $messageId = null, $server = null, $fromCache = true, $seconds = 1);
// Notice that message should end with an "\r\n" character as suggested by RFC2822
$this->assertEquals($html, '<!DOCTYPE html><html><body><p>Updated</p><img src="'.asset('/images/transparent.gif').'" data="X-Client-Message-Id: [ null ]" alt="X-Client-Message-Id: [ null ]" width="0" height="0" style="visibility:hidden"></body></html>'."\r\n");
$this->assertEquals($cachedHtml, '<!DOCTYPE html><html><body><p>Original</p><img src="'.asset('/images/transparent.gif').'" data="X-Client-Message-Id: [ null ]" alt="X-Client-Message-Id: [ null ]" width="0" height="0" style="visibility:hidden"></body></html>'."\r\n");
// wait 2 seconds for cache to expire
sleep(2);
$cachedHtml = $campaign->getHtmlContent($subscriber = null, $messageId = null, $server = null, $fromCache = true);
$this->assertEquals($cachedHtml, $html);
}
public function test_campaign_subject_and_plain_content_handler()
{
// Campaign
$campaign = Mockery::mock(new Campaign(['name' => 'Campaign']));
$campaign->uid = 'mocked-uid';
// Email (content layer) — needed for plain/subject delegation and getPlainTextFooter()
$email = new Email(['plain' => null, 'subject' => null]);
$campaign->setRelation('email', $email);
$campaign->email_id = 1;
$campaign->getPlainContent();
$campaign->getSubject();
$this->assertTrue(true);
}
public function test_spintax_parser_just_works_and_does_not_break_css()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new GenerateSpintax());
// HTML with CSS
$html = '<style>div > p {color:red}</style><p>{Hi|Hello} everyone</p>';
// Transformed HTML with CSS retained
$transformedHtml = $pipeline->build()->process($html);
// Output
$this->assertTrue($transformedHtml == '<style>div > p {color:red}</style><p>Hello everyone</p>' || $transformedHtml = '<style>div > p {color:red}</style><p>Hi everyone</p>');
}
public function test_spintax_preserves_pipeless_tag_braces()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new GenerateSpintax());
// Pipe-less braces (subscriber tags) must survive untouched —
// regression: parser used to unwrap `{X}` as a 1-choice spintax.
$this->assertEquals(
'<p>Hello {SUBSCRIBER_FIRST_NAME}</p>',
$pipeline->build()->process('<p>Hello {SUBSCRIBER_FIRST_NAME}</p>')
);
// Mixed: real spintax resolves, pipe-less tag stays intact.
$out = $pipeline->build()->process('<p>{Hi|Hello} {SUBSCRIBER_FIRST_NAME}</p>');
$this->assertStringContainsString('{SUBSCRIBER_FIRST_NAME}', $out);
$this->assertStringNotContainsString('{Hi|Hello}', $out);
}
public function test_inject_message_id_to_body()
{
$pipeline = new PipelineBuilder();
$pipeline->add(new InjectMessageIdToBody('MSGID'));
// HTML with CSS
$html = '<body>Hello</body>';
// Transformed HTML with CSS retained
$transformedHtml = $pipeline->build()->process($html);
$imgUrl = asset('/images/transparent.gif');
// Output
$this->assertEquals(
$transformedHtml,
'<body>Hello<img src="'.$imgUrl.'" data="X-Client-Message-Id: MSGID" alt="X-Client-Message-Id: MSGID" width="0" height="0" style="visibility:hidden"></body>'
);
}
}