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 {}
}