File: /home/xedaptot/ai.naniguide.com/tests/Unit/WarmupQuotaServiceTest.php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Carbon\Carbon;
use Mockery;
use App\Model\SendingServer;
use App\Model\WarmupStrategy;
use App\Model\SendingServerWarmupUsage;
use App\Model\SendingServerWarmupLog;
use App\SendingServers\Drivers\DeliveryStatus;
use App\SendingServers\Drivers\SendResult;
use App\Services\Warmup\WarmupQuotaService;
use App\Services\Warmup\Exceptions\WarmupDailyQuotaExceeded;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Mime\Email;
class WarmupQuotaServiceTest extends TestCase
{
protected WarmupQuotaService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(WarmupQuotaService::class);
}
protected function tearDown(): void
{
Mockery::close();
Carbon::setTestNow();
parent::tearDown();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
protected function makeStrategy(array $overrides = []): WarmupStrategy
{
return WarmupStrategy::create(array_merge([
'name' => 'Test Strategy',
'description' => '',
'status' => WarmupStrategy::STATUS_ACTIVE,
'growth_strategy' => WarmupStrategy::GROWTH_STRATEGY_LINEAR,
'starting_volume' => 20,
'daily_increment' => 10,
'quota_type' => WarmupStrategy::QUOTA_TYPE_DAILY,
'limit_type' => WarmupStrategy::LIMIT_TYPE_PER_DAY_CAP,
'limit_per_day_cap' => 1000,
'increment_percentage' => 10,
'sending_limit' => 1000,
'sending_count' => 30,
], $overrides));
}
protected function mockServer(WarmupStrategy $strategy, string $startedAt): SendingServer
{
$id = DB::table('sending_servers')->insertGetId([
'uid' => Str::uuid(),
'name' => 'Test Server',
'type' => 'smtp',
'quota_value' => 60,
'quota_base' => 1,
'quota_unit' => 'hour',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->id = $id;
$server->warmup_started_at = Carbon::parse($startedAt);
$server->warmupStrategy = $strategy;
$server->shouldReceive('isWarmupEnabled')->andReturn(true);
$server->shouldReceive('loadMissing')->andReturnSelf();
return $server;
}
// -------------------------------------------------------------------------
// getDayNumber
// -------------------------------------------------------------------------
public function test_get_day_number_returns_1_when_started_today(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$server = new SendingServer();
$server->warmup_started_at = Carbon::parse('2026-03-21 00:00:00');
$this->assertSame(1, $this->service->getDayNumber($server));
}
public function test_get_day_number_returns_correct_day_offset(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$server = new SendingServer();
$server->warmup_started_at = Carbon::parse('2026-03-18 08:00:00');
$this->assertSame(4, $this->service->getDayNumber($server));
}
// -------------------------------------------------------------------------
// getDailyQuota
// -------------------------------------------------------------------------
public function test_get_daily_quota_returns_max_int_when_warmup_disabled(): void
{
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->shouldReceive('isWarmupEnabled')->once()->andReturn(false);
$this->assertSame(PHP_INT_MAX, $this->service->getDailyQuota($server));
}
public function test_get_daily_quota_returns_max_int_when_no_strategy(): void
{
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->warmupStrategy = null;
$server->shouldReceive('isWarmupEnabled')->once()->andReturn(true);
$server->shouldReceive('loadMissing')->once()->andReturnSelf();
$this->assertSame(PHP_INT_MAX, $this->service->getDailyQuota($server));
}
public function test_get_daily_quota_uses_linear_strategy(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = new WarmupStrategy();
$strategy->growth_strategy = WarmupStrategy::GROWTH_STRATEGY_LINEAR;
$strategy->starting_volume = 20;
$strategy->daily_increment = 10;
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->warmup_started_at = Carbon::parse('2026-03-19 00:00:00'); // day 3
$server->warmupStrategy = $strategy;
$server->shouldReceive('isWarmupEnabled')->once()->andReturn(true);
$server->shouldReceive('loadMissing')->once()->andReturnSelf();
// day 3 => 20 + (3 - 1) * 10 = 40
$this->assertSame(40, $this->service->getDailyQuota($server));
}
public function test_get_daily_quota_uses_exponential_strategy(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = new WarmupStrategy();
$strategy->growth_strategy = WarmupStrategy::GROWTH_STRATEGY_EXPONENTIAL;
$strategy->starting_volume = 50;
$strategy->exponential_factor = 1.2;
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->warmup_started_at = Carbon::parse('2026-03-19 00:00:00'); // day 3
$server->warmupStrategy = $strategy;
$server->shouldReceive('isWarmupEnabled')->once()->andReturn(true);
$server->shouldReceive('loadMissing')->once()->andReturnSelf();
// day 3 => round(50 * (1.2 ^ 2)) = round(72) = 72
$this->assertSame(72, $this->service->getDailyQuota($server));
}
// -------------------------------------------------------------------------
// remainingQuota
// -------------------------------------------------------------------------
public function test_remaining_quota_returns_null_when_warmup_disabled(): void
{
$server = Mockery::mock(SendingServer::class)->makePartial();
$server->shouldReceive('isWarmupEnabled')->once()->andReturn(false);
$this->assertNull($this->service->remainingQuota($server));
}
public function test_remaining_quota_creates_daily_usage_record(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy(['starting_volume' => 20, 'daily_increment' => 10]);
$server = $this->mockServer($strategy, '2026-03-20 00:00:00');
// day 2 => 20 + (2 - 1) * 10 = 30
$this->assertSame(30, $this->service->remainingQuota($server));
$this->assertDatabaseHas('sending_server_warmup_usages', [
'sending_server_id' => $server->id,
'usage_date' => '2026-03-21',
'day_number' => 2,
'daily_quota' => 30,
'reserved_count' => 0,
'sent_count' => 0,
]);
}
public function test_remaining_quota_deducts_existing_reserved_and_sent(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy(['starting_volume' => 20, 'daily_increment' => 10]);
$server = $this->mockServer($strategy, '2026-03-20 00:00:00');
SendingServerWarmupUsage::create([
'sending_server_id' => $server->id,
'usage_date' => '2026-03-21',
'day_number' => 2,
'daily_quota' => 30,
'reserved_count' => 10,
'sent_count' => 5,
'failed_count' => 0,
]);
// 30 - 10 (reserved) - 5 (sent) = 15
$this->assertSame(15, $this->service->remainingQuota($server));
}
// -------------------------------------------------------------------------
// reserveOrFail
// -------------------------------------------------------------------------
public function test_reserve_or_fail_creates_usage_increments_reserved_and_creates_log(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy();
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
$reservation = $this->service->reserveOrFail($server, [
'campaign_id' => 100,
'subscriber_id' => 200,
'message_id' => 'msg-001',
'email' => '[email protected]',
]);
$this->assertNotNull($reservation);
$usage = SendingServerWarmupUsage::where('sending_server_id', $server->id)
->where('usage_date', '2026-03-21')
->firstOrFail();
$this->assertSame(1, $usage->reserved_count);
$this->assertSame(0, $usage->sent_count);
$this->assertSame(0, $usage->failed_count);
$this->assertDatabaseHas('sending_server_warmup_logs', [
'sending_server_id' => $server->id,
'usage_date' => '2026-03-21',
'status' => 'reserved',
]);
}
public function test_reserve_or_fail_throws_when_daily_quota_exceeded(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy([
'starting_volume' => 1,
'daily_increment' => 0,
'limit_per_day_cap' => 1,
]);
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
SendingServerWarmupUsage::create([
'sending_server_id' => $server->id,
'usage_date' => '2026-03-21',
'day_number' => 1,
'daily_quota' => 1,
'reserved_count' => 1,
'sent_count' => 0,
'failed_count' => 0,
]);
$this->expectException(WarmupDailyQuotaExceeded::class);
try {
$this->service->reserveOrFail($server, []);
} catch (WarmupDailyQuotaExceeded $e) {
$this->assertNotNull($e->getRetryAt());
$this->assertGreaterThanOrEqual(60, $e->getDelayInSeconds());
throw $e;
}
}
// -------------------------------------------------------------------------
// markSent
// -------------------------------------------------------------------------
public function test_mark_sent_moves_reserved_to_sent_and_updates_log(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy();
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
$reservation = $this->service->reserveOrFail($server, ['message_id' => 'msg-001']);
$this->service->markSent($reservation, ['provider_message_id' => 'provider-xyz']);
$usage = SendingServerWarmupUsage::where('sending_server_id', $server->id)
->where('usage_date', '2026-03-21')
->firstOrFail();
$log = SendingServerWarmupLog::where('sending_server_id', $server->id)
->where('usage_date', '2026-03-21')
->firstOrFail();
$this->assertSame(0, $usage->reserved_count);
$this->assertSame(1, $usage->sent_count);
$this->assertSame(0, $usage->failed_count);
$this->assertSame('sent', $log->status);
$this->assertEquals('provider-xyz', $log->meta['provider_message_id']);
}
// -------------------------------------------------------------------------
// markFailed
// -------------------------------------------------------------------------
public function test_mark_failed_moves_reserved_to_failed_and_updates_log(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy();
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
$reservation = $this->service->reserveOrFail($server, ['message_id' => 'msg-001']);
$this->service->markFailed($reservation, new \Exception('SMTP timeout'));
$usage = SendingServerWarmupUsage::where('sending_server_id', $server->id)
->where('usage_date', '2026-03-21')
->firstOrFail();
$log = SendingServerWarmupLog::where('sending_server_id', $server->id)
->where('usage_date', '2026-03-21')
->firstOrFail();
$this->assertSame(0, $usage->reserved_count);
$this->assertSame(0, $usage->sent_count);
$this->assertSame(1, $usage->failed_count);
$this->assertSame('failed', $log->status);
$this->assertEquals('SMTP timeout', $log->meta['error']);
}
// -------------------------------------------------------------------------
// send
// -------------------------------------------------------------------------
public function test_send_marks_sent_when_server_send_succeeds(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy();
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
$message = (new Email())->from('[email protected]')->to('[email protected]')->subject('s')->text('b');
$expected = new SendResult(runtimeMessageId: 'rt-001', status: DeliveryStatus::SENT);
$server->shouldReceive('send')->once()->with($message)->andReturn($expected);
$result = $this->service->send($server, $message, ['message_id' => 'msg-001']);
$this->assertSame($expected, $result);
}
public function test_send_marks_failed_and_rethrows_when_server_send_fails(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy();
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
$message = (new Email())->from('[email protected]')->to('[email protected]')->subject('s')->text('b');
$server->shouldReceive('send')->once()->with($message)->andThrow(new \Exception('SMTP failed'));
$this->expectException(\Exception::class);
$this->expectExceptionMessage('SMTP failed');
$this->service->send($server, $message, ['message_id' => 'msg-001']);
}
public function test_quota_usage_and_logs_are_correctly_recorded_over_multiple_sends(): void
{
Carbon::setTestNow('2026-03-21 10:00:00');
$strategy = $this->makeStrategy(['starting_volume' => 2, 'daily_increment' => 1]);
$server = $this->mockServer($strategy, '2026-03-21 00:00:00');
// Send 1st message (day 1 quota = 2)
$reservation1 = $this->service->reserveOrFail($server, ['message_id' => 'msg-001']);
$this->service->markSent($reservation1);
// Send 2nd message (day 1 quota = 2)
$reservation2 = $this->service->reserveOrFail($server, ['message_id' => 'msg-002']);
$this->service->markSent($reservation2);
// Advance to day 2
Carbon::setTestNow('2026-03-22 10:00:00');
// Send 3rd message (day 2 quota = 3)
$reservation3 = $this->service->reserveOrFail($server, ['message_id' => 'msg-003']);
$this->service->markSent($reservation3);
$this->assertEquals([
'2026-03-21' => ['day_number' => 1, 'daily_quota' => 2, 'reserved_count' => 0, 'sent_count' => 2, 'failed_count' => 0],
'2026-03-22' => ['day_number' => 2, 'daily_quota' => 3, 'reserved_count' => 0, 'sent_count' => 1, 'failed_count' => 0],
], SendingServerWarmupUsage::where('sending_server_id', $server->id)
->orderBy('usage_date')
->get()
->keyBy(fn ($u) => $u->usage_date->format('Y-m-d'))
->map(fn ($u) => [
'day_number' => $u->day_number,
'daily_quota' => $u->daily_quota,
'reserved_count' => $u->reserved_count,
'sent_count' => $u->sent_count,
'failed_count' => $u->failed_count,
])
->all());
$this->assertEquals([
'msg-001' => ['usage_date' => '2026-03-21', 'status' => 'sent'],
'msg-002' => ['usage_date' => '2026-03-21', 'status' => 'sent'],
'msg-003' => ['usage_date' => '2026-03-22', 'status' => 'sent'],
], SendingServerWarmupLog::where('sending_server_id', $server->id)
->orderBy('message_id')
->get()
->keyBy('message_id')
->map(fn ($l) => [
'usage_date' => $l->usage_date->format('Y-m-d'),
'status' => $l->status,
])
->all());
}
}