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/CampaignFlowTest.php
<?php

/**
 * CampaignFlowTest — integration test for the full campaign sending pipeline.
 *
 * ─── REVIEW: Expected behaviors vs code ──────────────────────────────────────
 *
 * ✅ Campaign creation → Customer::newDefaultCampaign() + saveFromArray()
 *      Campaign::save() override auto-creates an Email record when email_id is
 *      not yet set and EMAIL_ATTRIBUTES are present in the attribute bag.
 *
 * ✅ Email/template → Email::setTemplate() copies the template as private,
 *      assigns template_id on Email, triggers updatePlainFromHtml().
 *      Campaign accesses template via getAttribute('template') delegation.
 *
 * ✅ List association → Campaign::addListSegment() + default_mail_list_id.
 *      Campaign::getServersPool() reads from defaultMailList->getSendingServers().
 *
 * ✅ Run → execute() dispatches RunCampaign job, which calls Campaign::run().
 *      run() builds a Bus batch of LoadCampaign jobs (one per page of subs).
 *      Each LoadCampaign adds SendMessage jobs into the same batch.
 *      With QUEUE_CONNECTION=sync all jobs run inline.
 *
 * ✅ ContinueCampaign → fires from batch "then" callback, loops or calls setDone().
 *
 * ✅ Tracking → Campaign::trackMessage() writes one row per attempt to
 *      tracking_logs (status='sent'|'failed').
 *
 * ⚠️  "Failed delivery → campaign ERROR" depends on skip_failed_message:
 *      - skip_failed_message = FALSE  → stopOnError = TRUE
 *        First exception is rethrown → batch catch → setError() → STATUS_ERROR
 *      - skip_failed_message = TRUE   → stopOnError = FALSE
 *        Exception is swallowed, tracking_log written as 'failed',
 *        campaign continues → STATUS_DONE
 *      Both paths are exercised in the tests below.
 *
 * ─── Test DB requirement ─────────────────────────────────────────────────────
 *
 * These tests use DatabaseTransactions (wraps each test in a rolled-back DB
 * transaction).  The suite runs against whatever `DB_CONNECTION` is configured
 * in phpunit.xml.  For local development that is typically the dev MySQL DB.
 * If you need full isolation, point `DB_DATABASE` at a dedicated test schema
 * and run `php artisan migrate --env=testing` once before this suite.
 *
 * QUEUE_CONNECTION=sync (set in phpunit.xml) makes every dispatched job and
 * batch callback execute synchronously inside the same PHP process.
 */

use App\Model\Campaign;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\Email;
use App\Model\MailList;
use App\Model\Plan;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\Subscription;
use App\Model\Template;
use App\Model\TrackingLog;
use App\SendingServers\Drivers\DeliveryStatus;
use App\SendingServers\Drivers\SendResult;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Entitlements\EntitlementKey;
use App\Services\Warmup\WarmupQuotaService;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

uses(TestCase::class);

// Clean up test data after each test (can't use DatabaseTransactions — Bus batch
// uses DB::transaction() internally which conflicts with savepoint-based rollbacks).
afterEach(function () {
    $testCustomerIds = DB::table('customers')->where('uid', 'like', 'cust_%')->pluck('id');

    DB::table('tracking_logs')->whereIn('customer_id', $testCustomerIds)->delete();

    $campaignIds = DB::table('campaigns')->whereIn('customer_id', $testCustomerIds)->pluck('id');
    DB::table('campaigns_lists_segments')->whereIn('campaign_id', $campaignIds)->delete();
    DB::table('campaigns')->whereIn('id', $campaignIds)->delete();

    DB::table('automation_emails')->whereIn('customer_id', $testCustomerIds)->delete();
    DB::table('emails')->whereIn('customer_id', $testCustomerIds)->delete();

    DB::table('subscribers')->where('email', 'like', '%campaign-test%')->delete();
    DB::table('mail_lists')->where('uid', 'like', 'list_%')->delete();
    DB::table('sending_servers')->where('uid', 'like', 'srv_%')->delete();
    DB::table('templates')->where('uid', 'like', 'tpl_%')->delete();
    DB::table('subscriptions')->whereIn('customer_id', $testCustomerIds)->delete();

    $planIds = DB::table('plans')->where('uid', 'like', 'plan_%')->pluck('id');
    DB::table('plan_entitlements')->whereIn('plan_id', $planIds)->delete();
    DB::table('plan_credits')->whereIn('plan_id', $planIds)->delete();
    DB::table('plans')->whereIn('id', $planIds)->delete();

    DB::table('customers')->whereIn('id', $testCustomerIds)->delete();
    DB::table('contacts')->where('uid', 'like', 'contact_%')->delete();
});

// ─── Helpers ──────────────────────────────────────────────────────────────────

/**
 * Build the minimal DB fixture needed for a campaign run:
 *  customer → mail list + subscribers → sending server (SMTP, no-op setup) → template
 */
function campaignFixture(int $subscriberCount = 3): array
{
    // 1. Customer — email/name live in the contacts table, not customers
    $contact = Contact::forceCreate([
        'uid'        => uniqid('contact_'),
        'first_name' => 'Test',
        'last_name'  => 'User',
        'email'      => uniqid() . '@campaign-test.invalid',
    ]);
    $customer = Customer::forceCreate([
        'uid'        => uniqid('cust_'),
        'contact_id' => $contact->id,
        'status'     => 'active',
        'timezone'   => 'UTC',
    ]);

    // 1b. Plan + active subscription so the SaaS pipeline (sending-server lookup,
    // rate-limit cleanup, footer-from-plan) finds a real plan instead of erroring.
    $plan = Plan::forceCreate([
        'uid'              => uniqid('plan_'),
        'currency_id'      => 1,
        'name'             => 'Test Plan',
        'price'            => 0,
        'frequency_amount' => 1,
        'frequency_unit'   => 'month',
        'status'           => 'active',
    ]);
    DB::table('plan_entitlements')->insert([
        'plan_id'         => $plan->id,
        'entitlement_key' => EntitlementKey::HAS_OWN_SENDING_SERVER->value,
        'enabled'         => true,
        'created_at'      => now(),
        'updated_at'      => now(),
    ]);
    // Unlimited send-email credit (NULL = unlimited per migration comment)
    DB::table('plan_credits')->insert([
        'plan_id'          => $plan->id,
        'credit_key'       => CreditKey::SEND_EMAIL->value,
        'amount_per_cycle' => null,
        'created_at'       => now(),
        'updated_at'       => now(),
    ]);
    $subscription = Subscription::forceCreate([
        'uid'         => uniqid('sub_'),
        'customer_id' => $customer->id,
        'plan_id'     => $plan->id,
        'status'      => Subscription::STATUS_ACTIVE,
    ]);

    // Mirror production assignPlan(): prime cold-state DB row + hot tracker
    // file via the lifecycle. Without this, trackerFor returns null and
    // SendPipeline can't access the credit tracker.
    app(\App\Services\Plans\Lifecycle\SubscriptionLifecycle::class)
        ->onSubscribe($subscription);

    // 2. Mail list
    $list = MailList::forceCreate([
        'uid'         => uniqid('list_'),
        'name'        => 'Test List',
        'customer_id' => $customer->id,
        'from_email'  => '[email protected]',
        'from_name'   => 'Test Sender',
    ]);

    // 3. Sending server (type=smtp, setupBeforeSend() is a no-op on SMTP)
    $server = SendingServer::forceCreate([
        'uid'         => uniqid('srv_'),
        'name'        => 'Test SMTP',
        'type'        => 'smtp',
        'customer_id' => $customer->id,
        'status'      => SendingServer::STATUS_ACTIVE,
        'host'        => '127.0.0.1',
        'smtp_port'   => 25,
        'quota_value' => -1,  // unlimited
        'quota_base'  => 1,
        'quota_unit'  => 'day',
    ]);

    // The SendingServer above lives under $customer; Customer::getSendingServerPool()
    // (Case A — HAS_OWN_SENDING_SERVER entitlement granted above) surfaces it
    // for both campaign delivery and list transactional sends.

    // 5. Subscribers
    $subscribers = [];
    for ($i = 0; $i < $subscriberCount; $i++) {
        $subscribers[] = Subscriber::forceCreate([
            'uid'                 => uniqid('sub_'),
            'email'               => "sub{$i}_" . uniqid() . '@campaign-test.invalid',
            'status'              => Subscriber::STATUS_SUBSCRIBED,
            'mail_list_id'        => $list->id,
            'verification_status' => 'unverified',  // included in default delivery statuses
            'from'                => 'manual',
            'ip'                  => '127.0.0.1',
        ]);
    }

    // 6. Template (plain HTML – no external CSS, no inline-CSS pipeline issues)
    $template = Template::forceCreate([
        'uid'     => uniqid('tpl_'),
        'name'    => 'Test Template',
        'content' => '<html><body><p>Hello!</p><a href="{UNSUBSCRIBE_URL}">Unsubscribe</a></body></html>',
        'json'    => \App\Model\Template::DEFAULT_BUILDER_JSON,
    ]);

    // Create a placeholder thumbnail so Template::copy() doesn't throw FileNotFoundException
    $thumbDir = storage_path('app/public/templates/thumb');
    if (!is_dir($thumbDir)) {
        mkdir($thumbDir, 0755, true);
    }
    file_put_contents($thumbDir . '/' . $template->uid . '.jpg', '');

    return compact('customer', 'list', 'server', 'subscribers', 'template');
}

/**
 * Build a campaign that is ready to run (template set, list associated).
 */
function readyCampaign(array $fixture): Campaign
{
    ['customer' => $customer, 'list' => $list, 'template' => $template] = $fixture;

    // Create campaign — email record is auto-created inside save()
    $campaign = $customer->local()->newDefaultCampaign();
    $campaign->saveFromArray(['type' => Email::TYPE_REGULAR]);

    // Content
    $campaign->setTemplate($template, 'Test campaign template');
    $campaign->subject    = 'Test Campaign Subject';
    $campaign->from_email = '[email protected]';
    $campaign->from_name  = 'Test Sender';
    $campaign->reply_to   = '[email protected]';
    $campaign->save();

    // List
    $campaign->addListSegment($list, null);
    $campaign->default_mail_list_id = $list->id;
    $campaign->save();

    return $campaign->fresh(['email']);
}

// ─── Tests ────────────────────────────────────────────────────────────────────

test('creating a campaign auto-creates an Email record', function () {
    $fixture  = campaignFixture(0);
    $customer = $fixture['customer'];

    $campaign = $customer->local()->newDefaultCampaign();

    // Before save: no email_id yet, but type/delivery_statuses are pending in attributes
    expect($campaign->email_id)->toBeNull();

    $campaign->saveFromArray(['type' => Email::TYPE_REGULAR]);
    $campaign->refresh();

    // After save: Email is created and linked
    expect($campaign->email_id)->not->toBeNull();
    expect($campaign->email)->not->toBeNull();
    expect($campaign->type)->toBe(Email::TYPE_REGULAR);      // delegated to Email
    expect($campaign->status)->toBe(Campaign::STATUS_NEW);
});

test('template assignment is delegated to the Email record', function () {
    $fixture  = campaignFixture(0);
    $campaign = readyCampaign($fixture);

    expect($campaign->email->template_id)->not->toBeNull();
    expect($campaign->template)->not->toBeNull();             // delegation via getAttribute
    expect($campaign->getTemplateContent())->toContain('<html>');
    expect($campaign->subject)->toBe('Test Campaign Subject'); // delegation via email
});

test('campaign happy path: all subscribers receive the email, status becomes DONE', function () {
    $fixture    = campaignFixture(subscriberCount: 3);
    $campaign   = readyCampaign($fixture);
    $campaignId = $campaign->id;

    // Mock WarmupQuotaService to return a successful delivery for every subscriber
    $this->mock(WarmupQuotaService::class, function ($mock) {
        $mock->shouldReceive('send')
             ->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid('msg_'), status: DeliveryStatus::SENT));
    });

    // Run — with QUEUE_CONNECTION=sync this is fully inline
    $origException = null;
    DB::listen(function ($query) use (&$origException) {
        // no-op listener, just to ensure queries are logged
    });
    set_exception_handler(function ($e) use (&$origException) {
        $origException = $e;
    });
    try {
        $campaign->execute();
    } catch (\Throwable $outer) {
        if ($origException && $origException !== $outer) {
            throw $origException;
        }
        throw $outer;
    } finally {
        restore_exception_handler();
    }

    $campaign->refresh();

    // ── Status
    expect($campaign->status)->toBe(Campaign::STATUS_DONE);
    expect($campaign->last_error)->toBeNull();

    // ── Tracking logs: one entry per subscriber, all 'sent'
    $logs = TrackingLog::where('campaign_id', $campaignId)->get();

    expect($logs)->toHaveCount(3);
    expect($logs->every(fn ($log) => $log->status === 'sent'))->toBeTrue();
    expect($logs->every(fn ($log) => !empty($log->message_id)))->toBeTrue();
    expect($logs->every(fn ($log) => !empty($log->runtime_message_id)))->toBeTrue();

    // ── All 3 subscribers are covered — no one was skipped
    $sentEmails = $logs->pluck('subscriber_id');
    $allSubIds  = collect($fixture['subscribers'])->pluck('id');
    expect($sentEmails->diff($allSubIds))->toBeEmpty();
});

test('failed delivery with skip_failed_message=true: logs as failed, campaign still reaches DONE', function () {
    $fixture  = campaignFixture(subscriberCount: 2);
    $campaign = readyCampaign($fixture);

    // skip_failed_message=true → stopOnError=false → individual failures are absorbed
    $campaign->email->skip_failed_message = true;
    $campaign->email->save();

    $this->mock(WarmupQuotaService::class, function ($mock) {
        $mock->shouldReceive('send')
             ->andThrow(new RuntimeException('Simulated SMTP failure'));
    });

    $campaign->execute();
    $campaign->refresh();

    // Campaign should complete — errors are tolerated
    expect($campaign->status)->toBe(Campaign::STATUS_DONE);

    $logs = TrackingLog::where('campaign_id', $campaign->id)->get();
    expect($logs)->toHaveCount(2);
    expect($logs->every(fn ($log) => $log->status === 'failed'))->toBeTrue();
    expect($logs->every(fn ($log) => !empty($log->error)))->toBeTrue();
});

test('failed delivery with skip_failed_message=false: campaign stops with ERROR status', function () {
    $fixture  = campaignFixture(subscriberCount: 2);
    $campaign = readyCampaign($fixture);

    // skip_failed_message=false → stopOnError=true → first exception stops the batch
    $campaign->email->skip_failed_message = false;
    $campaign->email->save();

    $this->mock(WarmupQuotaService::class, function ($mock) {
        $mock->shouldReceive('send')
             ->andThrow(new RuntimeException('Simulated hard failure'));
    });

    $campaign->execute();
    $campaign->refresh();

    expect($campaign->is_error)->toBeTrue();
    expect($campaign->last_error)->not->toBeEmpty();
});

test('campaign status transitions: new → queued → sending → done', function () {
    $fixture  = campaignFixture(subscriberCount: 1);
    $campaign = readyCampaign($fixture);

    $statuses = [];

    // Intercept every save() to record status snapshots.
    // NOTE: $model->attributes goes through __get() (protected property), so use getAttributes().
    Campaign::saving(function ($model) use (&$statuses) {
        $attrs = $model->getAttributes();
        if (array_key_exists('status', $attrs)) {
            $statuses[] = $attrs['status'];
        }
    });

    $this->mock(WarmupQuotaService::class, function ($mock) {
        $mock->shouldReceive('send')
             ->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT));
    });

    expect($campaign->status)->toBe(Campaign::STATUS_NEW);

    $campaign->execute();
    $campaign->refresh();

    expect($campaign->status)->toBe(Campaign::STATUS_DONE);

    // The recorded transitions must include queued → sending → done (order guaranteed)
    expect($statuses)->toContain(Campaign::STATUS_QUEUED);
    expect($statuses)->toContain(Campaign::STATUS_SENDING);
    expect($statuses)->toContain(Campaign::STATUS_DONE);

    $queuedIdx  = array_search(Campaign::STATUS_QUEUED, $statuses);
    $sendingIdx = array_search(Campaign::STATUS_SENDING, $statuses);
    $doneIdx    = array_search(Campaign::STATUS_DONE, $statuses);

    expect($queuedIdx)->toBeLessThan($sendingIdx);
    expect($sendingIdx)->toBeLessThan($doneIdx);
});

test('subscribers already in tracking_logs are not sent to again (no duplicates)', function () {
    $fixture  = campaignFixture(subscriberCount: 3);
    $campaign = readyCampaign($fixture);

    $this->mock(WarmupQuotaService::class, function ($mock) {
        $mock->shouldReceive('send')
             ->andReturnUsing(fn() => new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT));
    });

    $campaign->execute();
    $campaign->refresh();

    $firstRunCount = TrackingLog::where('campaign_id', $campaign->id)->count();
    expect($firstRunCount)->toBe(3);

    // Resetting status to NEW and re-running should not create duplicate logs
    // (subscribersToSend() excludes already-logged subscribers)
    $campaign->status = Campaign::STATUS_NEW;
    $campaign->save();

    $campaign->execute($force = true);
    $campaign->refresh();

    $secondRunCount = TrackingLog::where('campaign_id', $campaign->id)->count();
    expect($secondRunCount)->toBe(3); // still 3, not 6
});

test('subscriber partition: A = B + C + D (toSend + sent + skipped)', function () {
    // Create fixture with 3 subscribed subscribers
    $fixture  = campaignFixture(subscriberCount: 3);
    $campaign = readyCampaign($fixture);
    $list     = $fixture['list'];

    // Add 1 unsubscribed subscriber (should be skipped)
    $unsubscribed = Subscriber::forceCreate([
        'uid'                 => uniqid('sub_unsub_'),
        'email'               => 'unsub_' . uniqid() . '@campaign-test.invalid',
        'status'              => 'unsubscribed',
        'mail_list_id'        => $list->id,
        'verification_status' => 'unverified',
        'from'                => 'manual',
        'ip'                  => '127.0.0.1',
    ]);

    // Add 1 subscriber with undeliverable verification (should be skipped)
    $undeliverable = Subscriber::forceCreate([
        'uid'                 => uniqid('sub_undel_'),
        'email'               => 'undel_' . uniqid() . '@campaign-test.invalid',
        'status'              => Subscriber::STATUS_SUBSCRIBED,
        'mail_list_id'        => $list->id,
        'verification_status' => Subscriber::VERIFICATION_STATUS_UNDELIVERABLE,
        'from'                => 'manual',
        'ip'                  => '127.0.0.1',
    ]);

    // A = all subscribers in campaign lists (3 subscribed + 1 unsub + 1 undeliverable = 5)
    $totalA = $campaign->subscribers([])->count();
    expect($totalA)->toBe(5);

    // Before sending: B=3 (eligible), C=0 (none sent), D=2 (skipped)
    expect($campaign->subscribersToSend()->count())->toBe(3);
    expect($campaign->subscribersSent()->count())->toBe(0);
    expect($campaign->subscribersSkipped()->count())->toBe(2);
    expect($campaign->subscribersToSend()->count() + $campaign->subscribersSent()->count() + $campaign->subscribersSkipped()->count())->toBe($totalA);

    // Send to 2 out of 3 eligible subscribers (mock sends first 2, then fails on 3rd)
    $callCount = 0;
    $this->mock(WarmupQuotaService::class, function ($mock) use (&$callCount) {
        $mock->shouldReceive('send')
             ->andReturnUsing(function () use (&$callCount) {
                 $callCount++;
                 if ($callCount <= 2) {
                     return new SendResult(runtimeMessageId: uniqid(), status: DeliveryStatus::SENT);
                 }
                 throw new \RuntimeException('Simulated failure');
             });
    });

    $campaign->email->skip_failed_message = true;
    $campaign->email->save();

    $campaign->execute();
    $campaign->refresh();

    // After partial send: C=2 (sent) or C=3 (2 sent + 1 failed), B=remaining, D=2 (unchanged)
    $countB = $campaign->subscribersToSend()->count();
    $countC = $campaign->subscribersSent()->count();
    $countD = $campaign->subscribersSkipped()->count();

    // The partition must hold: A = B + C + D
    expect($countB + $countC + $countD)->toBe($totalA, "Partition failed: B($countB) + C($countC) + D($countD) != A($totalA)");

    // D should still be 2 (unsubscribed + undeliverable)
    expect($countD)->toBe(2);

    // C should be >= 2 (at least 2 sent successfully, 3rd may have tracking_log with error)
    expect($countC)->toBeGreaterThanOrEqual(2);

    // C = C1 + C2: subscribersSent() must equal deliveredCount (sent) + failedCount (failed)
    // Assumes no duplicate tracking_log entries in this test
    $countC1 = $campaign->deliveredCount(); // tracking_logs.status = 'sent'
    $countC2 = $campaign->failedCount();    // tracking_logs.status = 'failed'
    expect($countC1 + $countC2)->toBe($countC, "C partition failed: C1($countC1) + C2($countC2) != C($countC)");

    // At least 2 delivered successfully
    expect($countC1)->toBeGreaterThanOrEqual(2);

    // Verify no overlap: IDs should be disjoint
    $idsB = $campaign->subscribersToSend()->pluck('subscribers.id')->toArray();
    $idsC = $campaign->subscribersSent()->pluck('subscribers.id')->toArray();
    $idsD = $campaign->subscribersSkipped()->pluck('subscribers.id')->toArray();

    expect(array_intersect($idsB, $idsC))->toBeEmpty('B and C overlap');
    expect(array_intersect($idsB, $idsD))->toBeEmpty('B and D overlap');
    expect(array_intersect($idsC, $idsD))->toBeEmpty('C and D overlap');
});