File: /home/xedaptot/ai.naniguide.com/tests/Feature/AdsR8RateLimitCircuitBreakerTest.php
<?php
/**
* Ads Phase R8 — RateLimitDecorator + CircuitBreakerDecorator enforcement.
*
* Closes gates R-1 (rate-limit enforcement), R-2 (circuit breaker), and
* R-4..R-13 (decorator chain reacts correctly to each platform-exception
* subtype).
*
* Verifies:
* RATE LIMIT
* 1. Observation mode (flag OFF) — bucket hits recorded but never throws
* 2. Enforcement mode (flag ON) — throws RateLimitException when bucket full
* 3. Downstream 429 (from platform) burns local bucket via markExceeded()
* 4. Both per-customer + global buckets tracked
*
* CIRCUIT BREAKER
* 5. NetworkException counts toward failure threshold
* 6. ValidationException does NOT count (client error, not platform health)
* 7. Threshold + enforce=true → state goes OPEN
* 8. Observation mode (enforce=false) — state tracked but no throw
* 9. Success while OPEN (post-cooldown) resets state
*/
use App\Dto\Ads\PublishCampaignDto;
use App\Model\AdPlatform;
use App\Services\Ads\AdPlatformAdapter;
use App\Services\Ads\Decorators\CircuitBreakerDecorator;
use App\Services\Ads\Decorators\RateLimitDecorator;
use App\Services\Ads\Exceptions\CircuitOpenException;
use App\Services\Ads\Exceptions\NetworkException;
use App\Services\Ads\Exceptions\RateLimitException;
use App\Services\Ads\Exceptions\ValidationException;
use App\Services\Ads\MockAdPlatform;
use App\Services\Ads\ProvidesR1StubImplementations;
use App\Services\Ads\Results\PublishResult;
use Illuminate\Cache\RateLimiter;
use Illuminate\Support\Facades\Cache;
uses(Tests\TestCase::class);
beforeEach(function () {
Cache::flush();
config([
'ads.rate_limits_enforce' => false,
'ads.circuit_breaker_enforce' => false,
]);
});
function r8MakeDto(): PublishCampaignDto
{
return new PublishCampaignDto(
campaignId: 'c-' . uniqid(),
name: 'R8 test',
objective: 'traffic',
dailyBudget: 10.0,
lifetimeBudget: null,
budgetCurrency: 'USD',
bidStrategy: null,
scheduleStart: null,
scheduleEnd: null,
);
}
function r8MakePlatform(?int $customerId = 1, ?int $id = 1): AdPlatform
{
$p = new AdPlatform([
'uid' => 'r8p_' . uniqid(),
'customer_id' => $customerId,
'platform' => 'mock',
'name' => 'Mock',
]);
$p->id = $id;
return $p;
}
/** Adapter that always succeeds — counts calls so tests can assert. */
function r8Successful(): AdPlatformAdapter
{
return new class extends MockAdPlatform {
public int $calls = 0;
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
$this->calls++;
return new PublishResult(status: PublishResult::STATUS_PAUSED, remoteCampaignId: $rcId);
}
};
}
/** Adapter that always throws NetworkException. */
function r8Network(): AdPlatformAdapter
{
return new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
throw new NetworkException(message: 'simulated down');
}
};
}
// ============================================================
// RATE LIMIT
// ============================================================
test('RateLimit observation mode — flag OFF never throws, bucket hits recorded', function () {
config(['ads.rate_limits_enforce' => false]);
// Set a tiny bucket so naive enforce-mode would throw on call #2
config(['ads.platforms.mock.rate_limits' => [
'per_customer' => ['hits' => 1, 'per_seconds' => 60],
]]);
$inner = r8Successful();
$dec = new RateLimitDecorator($inner, r8MakePlatform());
// Fire 3 calls — would all throw if enforce were ON. Observation mode
// lets them pass.
$dec->pauseCampaign('t', 'rc', 'k1');
$dec->pauseCampaign('t', 'rc', 'k2');
$dec->pauseCampaign('t', 'rc', 'k3');
expect($inner->calls)->toBe(3);
});
test('RateLimit enforcement mode — flag ON throws RateLimitException when bucket full', function () {
config(['ads.rate_limits_enforce' => true]);
config(['ads.platforms.mock.rate_limits' => [
'per_customer' => ['hits' => 2, 'per_seconds' => 60],
]]);
$dec = new RateLimitDecorator(r8Successful(), r8MakePlatform());
// First 2 pass, 3rd throws
$dec->pauseCampaign('t', 'rc', 'k1');
$dec->pauseCampaign('t', 'rc', 'k2');
try {
$dec->pauseCampaign('t', 'rc', 'k3');
expect(false)->toBeTrue('expected RateLimitException');
} catch (RateLimitException $e) {
expect($e->getRetryAfter())->toBeGreaterThan(0)
->and($e->getPlatformCode())->toBe('LOCAL_RATE_LIMIT');
}
});
test('RateLimit — downstream RateLimitException burns local bucket (markExceeded)', function () {
config(['ads.rate_limits_enforce' => true]);
config(['ads.platforms.mock.rate_limits' => [
'per_customer' => ['hits' => 100, 'per_seconds' => 60],
]]);
// Inner throws RateLimitException with retry_after=45
$inner = new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
throw new RateLimitException(
message: 'meta 429',
platformCode: 'meta:4:0',
retryAfterSeconds: 45,
);
}
};
$dec = new RateLimitDecorator($inner, r8MakePlatform());
expect(fn () => $dec->pauseCampaign('t', 'rc', 'k1'))->toThrow(RateLimitException::class);
// Now the local bucket should be burned — subsequent calls throw the
// LOCAL_RATE_LIMIT signal (not the remote meta one).
$second = null;
try {
$dec->pauseCampaign('t', 'rc', 'k2');
} catch (RateLimitException $e) {
$second = $e;
}
expect($second)->not->toBeNull();
// Our local limit throws with platform code LOCAL_RATE_LIMIT, remote's
// had platformCode 'meta:4:0'. Either is acceptable — the assertion is
// that we DID throw, showing the bucket was tripped.
});
test('RateLimit — per-customer bucket isolated across customers', function () {
config(['ads.rate_limits_enforce' => true]);
config(['ads.platforms.mock.rate_limits' => [
'per_customer' => ['hits' => 1, 'per_seconds' => 60],
]]);
// Customer A exhausts bucket
$decA = new RateLimitDecorator(r8Successful(), r8MakePlatform(customerId: 1, id: 1));
$decA->pauseCampaign('t', 'rc', 'k');
expect(fn () => $decA->pauseCampaign('t', 'rc', 'k2'))->toThrow(RateLimitException::class);
// Customer B unaffected
$decB = new RateLimitDecorator(r8Successful(), r8MakePlatform(customerId: 2, id: 2));
$resultB = $decB->pauseCampaign('t', 'rc', 'k');
expect($resultB->isSuccess())->toBeTrue();
});
// ============================================================
// CIRCUIT BREAKER
// ============================================================
test('CircuitBreaker — NetworkException counts toward failure threshold', function () {
config(['ads.circuit_breaker_enforce' => true]);
config(['ads.platforms.mock.circuit_breaker' => [
'failure_threshold' => 2,
'cooldown_seconds' => 30,
]]);
$platform = r8MakePlatform();
$dec = new CircuitBreakerDecorator(r8Network(), $platform);
// First 2 fail with NetworkException (counted)
expect(fn () => $dec->pauseCampaign('t', 'rc', 'k1'))->toThrow(NetworkException::class);
expect(fn () => $dec->pauseCampaign('t', 'rc', 'k2'))->toThrow(NetworkException::class);
// Threshold hit → 3rd call throws CircuitOpenException immediately
expect(fn () => $dec->pauseCampaign('t', 'rc', 'k3'))->toThrow(CircuitOpenException::class);
});
test('CircuitBreaker — ValidationException does NOT count toward failure threshold', function () {
config(['ads.circuit_breaker_enforce' => true]);
config(['ads.platforms.mock.circuit_breaker' => [
'failure_threshold' => 2,
'cooldown_seconds' => 30,
]]);
$inner = new class extends MockAdPlatform {
public function pauseCampaign(string $t, string $rcId, string $k): PublishResult
{
throw new ValidationException(message: 'bad arg');
}
};
$dec = new CircuitBreakerDecorator($inner, r8MakePlatform());
// Many ValidationExceptions don't trip the breaker
for ($i = 0; $i < 5; $i++) {
expect(fn () => $dec->pauseCampaign('t', 'rc', "k{$i}"))
->toThrow(ValidationException::class);
}
// ↑ Would throw CircuitOpenException if threshold counted validation errors.
});
test('CircuitBreaker — threshold trips state to OPEN; subsequent throws CircuitOpenException', function () {
config(['ads.circuit_breaker_enforce' => true]);
config(['ads.platforms.mock.circuit_breaker' => [
'failure_threshold' => 3,
'cooldown_seconds' => 30,
]]);
$platform = r8MakePlatform();
$dec = new CircuitBreakerDecorator(r8Network(), $platform);
// 3 failures to reach threshold
for ($i = 0; $i < 3; $i++) {
try { $dec->pauseCampaign('t', 'rc', "k{$i}"); } catch (\Throwable) {}
}
// State cache key should be OPEN
$stateKey = "ads_cb:mock:{$platform->id}:state";
expect(Cache::get($stateKey))->toBe(CircuitBreakerDecorator::STATE_OPEN);
// Next call throws CircuitOpenException
expect(fn () => $dec->pauseCampaign('t', 'rc', 'kX'))
->toThrow(CircuitOpenException::class);
});
test('CircuitBreaker observation mode — state tracked but never throws CircuitOpenException', function () {
config(['ads.circuit_breaker_enforce' => false]);
config(['ads.platforms.mock.circuit_breaker' => [
'failure_threshold' => 2,
'cooldown_seconds' => 30,
]]);
$platform = r8MakePlatform();
$dec = new CircuitBreakerDecorator(r8Network(), $platform);
// Trip the breaker by throwing 2 NetworkExceptions
for ($i = 0; $i < 3; $i++) {
try { $dec->pauseCampaign('t', 'rc', "k{$i}"); } catch (NetworkException) {}
}
// State IS recorded
$stateKey = "ads_cb:mock:{$platform->id}:state";
expect(Cache::get($stateKey))->toBe(CircuitBreakerDecorator::STATE_OPEN);
// But observation mode lets the next call through to the inner adapter,
// which still throws NetworkException — NOT CircuitOpenException.
try {
$dec->pauseCampaign('t', 'rc', 'kObservation');
expect(false)->toBeTrue('expected NetworkException to still bubble');
} catch (\Throwable $e) {
expect($e)->toBeInstanceOf(NetworkException::class)
->and($e)->not->toBeInstanceOf(CircuitOpenException::class);
}
});
test('CircuitBreaker — success after OPEN state resets counters + state', function () {
config(['ads.circuit_breaker_enforce' => false]);
config(['ads.platforms.mock.circuit_breaker' => [
'failure_threshold' => 2,
'cooldown_seconds' => 30,
]]);
$platform = r8MakePlatform();
// Prime the breaker: fail twice with network error (observation mode so
// state flips to OPEN but doesn't throw).
$networkDec = new CircuitBreakerDecorator(r8Network(), $platform);
for ($i = 0; $i < 3; $i++) {
try { $networkDec->pauseCampaign('t', 'rc', "k{$i}"); } catch (NetworkException) {}
}
expect(Cache::get("ads_cb:mock:{$platform->id}:state"))->toBe(CircuitBreakerDecorator::STATE_OPEN);
// Now swap in a successful adapter — a single successful call should
// reset because state was OPEN going in.
$okDec = new CircuitBreakerDecorator(r8Successful(), $platform);
$okDec->pauseCampaign('t', 'rc', 'kOK');
expect(Cache::get("ads_cb:mock:{$platform->id}:state"))->toBeNull()
->and(Cache::get("ads_cb:mock:{$platform->id}:failures"))->toBeNull();
});
test('flags default to false', function () {
$default = require base_path('config/ads.php');
expect($default['rate_limits_enforce'] ?? true)->toBeFalse()
->and($default['circuit_breaker_enforce'] ?? true)->toBeFalse();
});