HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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&amp;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&amp;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&amp;message_id=TVNHSUQ">Protocol relative</a><a href="mailto:[email protected]">resource url</a><a href="http://example.com/p/click?url=aHR0cDovL2V4YW1wbGUuY29tL3Vuc3Vic2NyaWJl&amp;message_id=TVNHSUQ">Opt-out</a><a href="http://example.com/p/click?url=aHR0cDovL2V4YW1wbGUuY29tL3VwZGF0ZS1wcm9maWxl&amp;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>'
        );
    }
}