test: add comprehensive tests for CircuitBreaker service

Cover all three circuit states (closed, open, half-open), failure
threshold tripping, reset timeout transitions, manual reset, probe
recovery, trial lock contention, success decay, service isolation,
recoverable error detection, and statistics tracking.

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:10:58 +00:00
parent 8c2a532899
commit fa867cfa7d
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View file

@ -0,0 +1,556 @@
<?php
declare(strict_types=1);
namespace Core\Mcp\Tests\Unit;
use Core\Mcp\Exceptions\CircuitOpenException;
use Core\Mcp\Services\CircuitBreaker;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use RuntimeException;
use Tests\TestCase;
class CircuitBreakerTest extends TestCase
{
protected CircuitBreaker $breaker;
protected function setUp(): void
{
parent::setUp();
$this->breaker = new CircuitBreaker;
Cache::flush();
}
// ---------------------------------------------------------------
// Closed state (default) - requests pass through
// ---------------------------------------------------------------
public function test_default_state_is_closed(): void
{
$state = $this->breaker->getState('test-service');
$this->assertSame(CircuitBreaker::STATE_CLOSED, $state);
}
public function test_closed_circuit_passes_operation_through(): void
{
$result = $this->breaker->call('test-service', fn () => 'success');
$this->assertSame('success', $result);
}
public function test_closed_circuit_returns_operation_result(): void
{
$result = $this->breaker->call('test-service', fn () => ['key' => 'value']);
$this->assertSame(['key' => 'value'], $result);
}
public function test_closed_circuit_propagates_exceptions(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('operation failed');
$this->breaker->call('test-service', function () {
throw new RuntimeException('operation failed');
});
}
public function test_closed_circuit_uses_fallback_on_recoverable_error(): void
{
$result = $this->breaker->call(
'test-service',
function () {
throw new RuntimeException('Connection refused');
},
fn () => 'fallback-value',
);
$this->assertSame('fallback-value', $result);
}
public function test_closed_circuit_does_not_use_fallback_on_non_recoverable_error(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('logic error');
$this->breaker->call(
'test-service',
function () {
throw new RuntimeException('logic error');
},
fn () => 'fallback-value',
);
}
public function test_service_is_available_when_closed(): void
{
$this->assertTrue($this->breaker->isAvailable('test-service'));
}
// ---------------------------------------------------------------
// Failure threshold - trips circuit after N failures
// ---------------------------------------------------------------
public function test_circuit_trips_after_reaching_failure_threshold(): void
{
// Default threshold is 5
for ($i = 0; $i < 5; $i++) {
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected
}
}
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
}
public function test_circuit_does_not_trip_below_threshold(): void
{
// Default threshold is 5, so 4 failures should not trip
for ($i = 0; $i < 4; $i++) {
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected
}
}
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
}
public function test_circuit_respects_custom_threshold(): void
{
Config::set('mcp.circuit_breaker.test-service.threshold', 2);
for ($i = 0; $i < 2; $i++) {
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected
}
}
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
}
public function test_service_is_not_available_when_open(): void
{
$this->tripCircuit('test-service');
$this->assertFalse($this->breaker->isAvailable('test-service'));
}
// ---------------------------------------------------------------
// Open state - fails fast
// ---------------------------------------------------------------
public function test_open_circuit_throws_circuit_open_exception(): void
{
$this->tripCircuit('test-service');
$this->expectException(CircuitOpenException::class);
$this->breaker->call('test-service', fn () => 'should not run');
}
public function test_open_circuit_exception_contains_service_name(): void
{
$this->tripCircuit('my-api');
try {
$this->breaker->call('my-api', fn () => 'should not run');
$this->fail('Expected CircuitOpenException');
} catch (CircuitOpenException $e) {
$this->assertSame('my-api', $e->service);
$this->assertStringContainsString('my-api', $e->getMessage());
}
}
public function test_open_circuit_uses_fallback_when_provided(): void
{
$this->tripCircuit('test-service');
$result = $this->breaker->call(
'test-service',
fn () => 'should not run',
fn () => 'fallback-result',
);
$this->assertSame('fallback-result', $result);
}
public function test_open_circuit_does_not_execute_operation(): void
{
$this->tripCircuit('test-service');
$executed = false;
try {
$this->breaker->call('test-service', function () use (&$executed) {
$executed = true;
});
} catch (CircuitOpenException) {
// Expected
}
$this->assertFalse($executed);
}
// ---------------------------------------------------------------
// Half-open state - probe / recovery
// ---------------------------------------------------------------
public function test_circuit_transitions_to_half_open_after_reset_timeout(): void
{
$this->tripCircuit('test-service');
// Simulate time passing beyond the reset timeout (default 60s)
$this->simulateTimePassing('test-service', 61);
$state = $this->breaker->getState('test-service');
$this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $state);
}
public function test_circuit_stays_open_before_reset_timeout(): void
{
$this->tripCircuit('test-service');
// Simulate time passing but not enough (default reset_timeout is 60s)
$this->simulateTimePassing('test-service', 30);
$state = $this->breaker->getState('test-service');
$this->assertSame(CircuitBreaker::STATE_OPEN, $state);
}
public function test_half_open_circuit_allows_probe_request(): void
{
$this->tripCircuit('test-service');
$this->simulateTimePassing('test-service', 61);
$result = $this->breaker->call('test-service', fn () => 'probe-success');
$this->assertSame('probe-success', $result);
}
public function test_half_open_circuit_closes_on_successful_probe(): void
{
$this->tripCircuit('test-service');
$this->simulateTimePassing('test-service', 61);
// Successful probe should close the circuit
$this->breaker->call('test-service', fn () => 'ok');
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
}
public function test_half_open_circuit_reopens_on_failed_probe(): void
{
Config::set('mcp.circuit_breaker.test-service.threshold', 1);
$this->tripCircuit('test-service');
$this->simulateTimePassing('test-service', 61);
// Failed probe should re-trip the circuit
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('still broken');
});
} catch (RuntimeException) {
// Expected
}
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
}
public function test_half_open_uses_fallback_when_trial_lock_taken(): void
{
$this->tripCircuit('test-service');
$this->simulateTimePassing('test-service', 61);
// Simulate another request holding the trial lock
Cache::put('circuit_breaker:test-service:trial_lock', true, 30);
$result = $this->breaker->call(
'test-service',
fn () => 'should not run',
fn () => 'locked-fallback',
);
$this->assertSame('locked-fallback', $result);
}
public function test_half_open_throws_when_trial_lock_taken_and_no_fallback(): void
{
$this->tripCircuit('test-service');
$this->simulateTimePassing('test-service', 61);
// Simulate another request holding the trial lock
Cache::put('circuit_breaker:test-service:trial_lock', true, 30);
$this->expectException(CircuitOpenException::class);
$this->breaker->call('test-service', fn () => 'should not run');
}
public function test_custom_reset_timeout(): void
{
Config::set('mcp.circuit_breaker.test-service.reset_timeout', 120);
$this->tripCircuit('test-service');
// 61 seconds is not enough with custom 120s timeout
$this->simulateTimePassing('test-service', 61);
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service'));
// 121 seconds should trigger half-open
$this->simulateTimePassing('test-service', 121);
$this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $this->breaker->getState('test-service'));
}
// ---------------------------------------------------------------
// Manual reset
// ---------------------------------------------------------------
public function test_manual_reset_closes_circuit(): void
{
$this->tripCircuit('test-service');
$this->breaker->reset('test-service');
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service'));
}
public function test_manual_reset_clears_failure_counters(): void
{
$this->tripCircuit('test-service');
$this->breaker->reset('test-service');
$stats = $this->breaker->getStats('test-service');
$this->assertSame(0, $stats['failures']);
$this->assertSame(0, $stats['successes']);
$this->assertNull($stats['last_failure']);
$this->assertNull($stats['opened_at']);
}
public function test_reset_allows_operations_again(): void
{
$this->tripCircuit('test-service');
$this->breaker->reset('test-service');
$result = $this->breaker->call('test-service', fn () => 'working again');
$this->assertSame('working again', $result);
}
// ---------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------
public function test_stats_track_failures(): void
{
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected
}
$stats = $this->breaker->getStats('test-service');
$this->assertSame(1, $stats['failures']);
$this->assertSame('test-service', $stats['service']);
$this->assertNotNull($stats['last_failure']);
$this->assertSame(RuntimeException::class, $stats['last_failure']['class']);
}
public function test_stats_track_successes(): void
{
$this->breaker->call('test-service', fn () => 'ok');
$stats = $this->breaker->getStats('test-service');
$this->assertSame(1, $stats['successes']);
}
public function test_stats_report_threshold_and_timeout(): void
{
$stats = $this->breaker->getStats('test-service');
$this->assertSame(5, $stats['threshold']);
$this->assertSame(60, $stats['reset_timeout']);
}
public function test_stats_report_custom_config(): void
{
Config::set('mcp.circuit_breaker.custom-svc.threshold', 10);
Config::set('mcp.circuit_breaker.custom-svc.reset_timeout', 180);
$stats = $this->breaker->getStats('custom-svc');
$this->assertSame(10, $stats['threshold']);
$this->assertSame(180, $stats['reset_timeout']);
}
// ---------------------------------------------------------------
// Recoverable error detection
// ---------------------------------------------------------------
public function test_sqlstate_error_is_recoverable(): void
{
$result = $this->breaker->call(
'test-service',
function () {
throw new RuntimeException('SQLSTATE[HY000]: General error');
},
fn () => 'recovered',
);
$this->assertSame('recovered', $result);
}
public function test_connection_timeout_is_recoverable(): void
{
$result = $this->breaker->call(
'test-service',
function () {
throw new RuntimeException('Connection timed out after 30 seconds');
},
fn () => 'recovered',
);
$this->assertSame('recovered', $result);
}
public function test_too_many_connections_is_recoverable(): void
{
$result = $this->breaker->call(
'test-service',
function () {
throw new RuntimeException('Too many connections');
},
fn () => 'recovered',
);
$this->assertSame('recovered', $result);
}
public function test_table_not_found_is_recoverable(): void
{
$result = $this->breaker->call(
'test-service',
function () {
throw new RuntimeException("Base table or view not found: 1146 Table 'db.table' doesn't exist");
},
fn () => 'recovered',
);
$this->assertSame('recovered', $result);
}
// ---------------------------------------------------------------
// Service isolation
// ---------------------------------------------------------------
public function test_circuits_are_isolated_per_service(): void
{
$this->tripCircuit('service-a');
// service-a should be open
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-a'));
// service-b should still be closed
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-b'));
}
public function test_resetting_one_service_does_not_affect_another(): void
{
$this->tripCircuit('service-a');
$this->tripCircuit('service-b');
$this->breaker->reset('service-a');
$this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-a'));
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-b'));
}
// ---------------------------------------------------------------
// Success decay
// ---------------------------------------------------------------
public function test_successful_calls_decay_failure_count(): void
{
// Record 3 failures
for ($i = 0; $i < 3; $i++) {
try {
$this->breaker->call('test-service', function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected
}
}
$this->assertSame(3, $this->breaker->getStats('test-service')['failures']);
// A successful call should decrement the failure count
$this->breaker->call('test-service', fn () => 'ok');
$this->assertSame(2, $this->breaker->getStats('test-service')['failures']);
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
/**
* Trip the circuit by reaching the failure threshold.
*/
protected function tripCircuit(string $service): void
{
$threshold = (int) config(
"mcp.circuit_breaker.{$service}.threshold",
config('mcp.circuit_breaker.default_threshold', 5),
);
for ($i = 0; $i < $threshold; $i++) {
try {
$this->breaker->call($service, function () {
throw new RuntimeException('Connection refused');
});
} catch (RuntimeException) {
// Expected - Connection refused is recoverable so fallback would be used
// but without a fallback it re-throws after recording the failure
}
}
// Verify the circuit actually tripped
$this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState($service));
}
/**
* Simulate time passing by adjusting the opened_at timestamp in cache.
*/
protected function simulateTimePassing(string $service, int $seconds): void
{
Cache::put(
"circuit_breaker:{$service}:opened_at",
time() - $seconds,
86400,
);
}
}