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

namespace Tests\Unit;

use App\Library\CreditTracker;
use App\Library\Exception\OutOfCredits;
use App\Library\Exception\RateLimitExceeded;
use App\Library\RateLimit;
use App\Library\RateTracker;
use App\Library\RouletteWheel;
use App\Library\Sending\JobControl;
use App\Library\Sending\Lifecycle\JobLifecycle;
use App\Library\Sending\SendContext;
use App\Library\Sending\SendPipeline;
use App\Model\Campaign;
use App\Model\Customer;
use App\Model\EmailVerificationServer;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\Subscription;
use App\Jobs\VerifySubscriber;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Credits\CreditsService;
use App\Services\Plans\RateLimits\RateLimitKey;
use App\Services\Plans\RateLimits\RateLimitsService;
use App\Services\Warmup\WarmupQuotaService;
use App\SendingServers\Drivers\DeliveryStatus;
use App\SendingServers\Drivers\SendResult;
use Carbon\Carbon;
use Mockery;
use Tests\TestCase;

/**
 * Migrated from the legacy `$sendMsgJob->send($exceptionCallback)` API after
 * the pipeline refactor. The two send-message scenarios now exercise the
 * SendPipeline directly, capturing rate-limit / credit-exhaustion via a
 * recording JobLifecycle (rate-limit, no-batch path) or by allowing the
 * OutOfCredits exception to bubble (force-end campaign).
 */
class QuotaTest extends TestCase
{
    public function initServer()
    {
        $server = new SendingServer();
        $server->generateUid();
        return $server;
    }

    public function initSubscription()
    {
        $subscription = new Subscription();
        $subscription->generateUid();
        return $subscription;
    }

    public function initEmailVerificationServer()
    {
        $server = new EmailVerificationServer();
        $server->generateUid();
        $server = Mockery::mock($server);
        $server->shouldReceive('verify')->andReturn(null);
        return $server;
    }

    private function prepareSendMessageTest()
    {
        // Server
        $server = $this->initServer();
        $server = Mockery::mock($server);
        $sentResult = new SendResult(runtimeMessageId: 'mock-msg-id', status: DeliveryStatus::SENT);
        $server->shouldReceive('send')->andReturn($sentResult);
        $server->shouldReceive('dryrun')->andReturn($sentResult);

        // Bypass the warmup wrapper — return SENT directly so the pipeline
        // exercises rate/credit acquisition (the part this test cares about)
        // without needing a real warmup quota DB row.
        $warmup = Mockery::mock(WarmupQuotaService::class);
        $warmup->shouldReceive('send')->andReturn($sentResult);
        $this->app->instance(WarmupQuotaService::class, $warmup);

        // Subscription
        $subscription = Mockery::mock($this->initSubscription());

        // Subscriber
        $subscriber = Mockery::mock(new Subscriber());
        $subscriber->shouldReceive('getEmail')->andReturn('[email protected]');
        $subscriber->shouldReceive('isSubscribed')->andReturn(true);

        // Campaign
        $campaign = Mockery::mock(new Campaign());
        $campaign->shouldReceive('getAttribute')->with('uid')->andReturn('camp-uid');
        $campaign->shouldReceive('getAttribute')->with('id')->andReturn(99);
        $campaign->shouldReceive('getAttribute')->with('email')->andReturn(new \App\Model\Email());
        $campaign->shouldReceive('prepareEmail')->andReturn([
            new \Symfony\Component\Mime\Email(),
            'msg-id-fake',
        ]);
        $campaign->shouldReceive('trackMessage')->andReturn(null);
        $campaign->shouldReceive('touchActivity');
        $campaign->shouldReceive('debug');

        return [$campaign, $subscriber, $server, $subscription];
    }

    public function test_send_message_job_with_rate_limit_exceeded(): void
    {
        list($campaign, $subscriber, $server, $subscription) = $this->prepareSendMessageTest();

        $server->shouldReceive('getRateLimitTracker')->andReturn(
            new RateTracker('/tmp/test-server-rate-log-'.uniqid(), [
                new RateLimit(2, 1, 'minute'),
            ])
        );

        $subRateTracker = new RateTracker('/tmp/test-subscription-rate-log-'.$subscription->uid, [
            new RateLimit(1, 1, 'minute'),    // 1 per minute - 2nd call should fail
        ]);
        $subCreditTracker = (new CreditTracker('/tmp/test-subscription-credit-'.$subscription->uid, true))->setCredits(100);

        $pool = new RouletteWheel();
        $pool->add($server, 100);

        $lifecycle = new RecordingLifecycleForQuota();
        $lifecycle->rateTrackersList = [$subRateTracker];
        $lifecycle->creditTrackersList = [$subCreditTracker];
        $control = new RecordingControlForQuota();

        // 1st run — succeeds, rate trackers count
        $ctx1 = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $pool);
        app(SendPipeline::class)->run($ctx1, $lifecycle, $control);

        $this->assertNull($lifecycle->rateLimitSeen);
        $this->assertEquals(1, $server->getRateLimitTracker()->getCreditsUsed(Carbon::parse('5 minutes ago')));
        $this->assertEquals(1, $subRateTracker->getCreditsUsed(Carbon::parse('5 minutes ago')));
        $this->assertEquals(99, $subCreditTracker->getRemainingCredits());

        // 2nd run — subscription rate limit hits, lifecycle->onRateLimitExceeded captures
        $pool2 = new RouletteWheel();
        $pool2->add($server, 100);
        $ctx2 = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $pool2);
        app(SendPipeline::class)->run($ctx2, $lifecycle, $control);

        $this->assertInstanceOf(RateLimitExceeded::class, $lifecycle->rateLimitSeen);
        $this->assertEquals(1, $subRateTracker->getCreditsUsed(Carbon::parse('5 minutes ago'))); // still 1 — second was rejected
        $this->assertEquals(99, $subCreditTracker->getRemainingCredits());                       // credit not consumed on rate-limit reject
    }

    public function test_send_message_job_with_credits_exceeded(): void
    {
        list($campaign, $subscriber, $server, $subscription) = $this->prepareSendMessageTest();

        $server->shouldReceive('getRateLimitTracker')->andReturn(
            new RateTracker('/tmp/test-server-rate-log-'.uniqid(), [
                new RateLimit(3, 1, 'minute'),
            ])
        );

        $subRateTracker = new RateTracker('/tmp/test-subscription-rate-log-'.$subscription->uid, [
            new RateLimit(2, 1, 'minute'),
        ]);
        $subCreditTracker = (new CreditTracker('/tmp/test-subscription-credit-'.$subscription->uid, true))->setCredits(1);

        $pool = new RouletteWheel();
        $pool->add($server, 100);

        $lifecycle = new RecordingLifecycleForQuota();
        $lifecycle->rateTrackersList = [$subRateTracker];
        $lifecycle->creditTrackersList = [$subCreditTracker];
        $control = new RecordingControlForQuota();

        // 1st run — succeeds, consumes the only credit
        $ctx1 = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $pool);
        app(SendPipeline::class)->run($ctx1, $lifecycle, $control);

        $this->assertEquals(0, $subCreditTracker->getRemainingCredits());

        // 2nd run — credits exhausted, OutOfCreditsHandler re-throws
        $pool2 = new RouletteWheel();
        $pool2->add($server, 100);
        $ctx2 = new SendContext(campaign: $campaign, subscriber: $subscriber, servers: $pool2);

        $this->expectException(OutOfCredits::class);
        app(SendPipeline::class)->run($ctx2, $lifecycle, $control);
    }

    public function test_email_verification_server_job_server_rate_limit_exceeded()
    {
        // Server
        $server = $this->initEmailVerificationServer();
        $list = Mockery::mock(new MailList());
        $customer = Mockery::mock(new Customer());
        $list->customer = $customer;
        $subscriber = Mockery::mock(new Subscriber());
        $subscriber->mailList = $list;
        $subscriber->shouldReceive('verify')->andReturn(null);

        $creditTracker = (new CreditTracker('/tmp/test-verify-credit-'.uniqid(), $createFileIfNotExist = true))->setCredits(100);
        $subscription = Mockery::mock($this->initSubscription());

        $credits = Mockery::mock(CreditsService::class);
        $credits->shouldReceive('trackerFor')->with($subscription, CreditKey::VERIFY_CONTACT)->andReturn($creditTracker);
        $this->app->instance(CreditsService::class, $credits);

        $file = storage_path('app/quota/verification-server-verify-email-rate-tracking-log-'.$server->uid);
        $server->shouldReceive('getRateTracker')->andReturn(
            new RateTracker($file, [
                new RateLimit(2, 1, 'minute'),
            ])
        );

        $verifyJob = new VerifySubscriber($subscriber, $server, $subscription);

        $outcome = null;
        $verifyJob->doVerify(function ($exception) use (&$outcome) {
            $outcome = $exception;
        });
        $this->assertNull($outcome);
        $this->assertEquals(1, $server->getRateTracker()->getCreditsUsed());
        $this->assertEquals(99, $creditTracker->getRemainingCredits());

        $verifyJob->doVerify(function ($exception) use (&$outcome) {
            $outcome = $exception;
        });
        $this->assertNull($outcome);
        $this->assertEquals(2, $server->getRateTracker()->getCreditsUsed());
        $this->assertEquals(98, $creditTracker->getRemainingCredits());

        $verifyJob->doVerify(function ($exception) use (&$outcome) {
            $outcome = $exception;
        });

        $this->assertEquals(98, $creditTracker->getRemainingCredits());
        $this->assertEquals(RateLimitExceeded::class, get_class($outcome));
        $this->assertEquals(2, $server->getRateTracker()->getCreditsUsed());
    }
}

class RecordingLifecycleForQuota implements JobLifecycle
{
    public ?RateLimitExceeded $rateLimitSeen = null;

    public function triggerId(): mixed
    {
        return null;
    }

    /** @var array<\App\Library\RateTrackerInterface> */
    public array $rateTrackersList = [];

    /** @var array<\App\Library\Contracts\CreditTrackerInterface> */
    public array $creditTrackersList = [];

    public function rateTrackers(): array
    {
        return $this->rateTrackersList;
    }

    public function creditTrackers(): array
    {
        return $this->creditTrackersList;
    }

    public function variantId(): ?int
    {
        return null;
    }

    public function prepareMessage(SendContext $ctx, \App\Model\SendingServer $server): array
    {
        return [new \Symfony\Component\Mime\Email(), 'msg-id-quota-test'];
    }

    public function onSent(SendContext $ctx): void
    {
    }

    public function onFailed(SendContext $ctx): void
    {
    }

    public function onRateLimitExceeded(SendContext $ctx, RateLimitExceeded $ex, JobControl $control): void
    {
        $this->rateLimitSeen = $ex;
    }

    public function onWarmupQuotaExceeded(SendContext $ctx, \App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded $ex, JobControl $control): void
    {
    }

    public function onOutOfCredits(SendContext $ctx, OutOfCredits $ex, JobControl $control): void
    {
        // QuotaTest exercises the full pipeline including OutOfCredits propagation.
        // Re-throw to keep the legacy "OutOfCredits bubbles to caller" contract
        // that the test asserts via $this->expectException(OutOfCredits::class).
        throw $ex;
    }

    public function onUnknownException(SendContext $ctx, \Throwable $ex, JobControl $control): void
    {
    }
}

class RecordingControlForQuota implements JobControl
{
    public ?int $releasedAfter = null;
    public function release(int $seconds): void { $this->releasedAfter = $seconds; }
    public function batch(): ?\Illuminate\Bus\Batch { return null; }
    public function addToBatch(object $job): void {}
}