feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Http\RequestContext;
|
||||
use App\Framework\LiveComponents\Attributes\TrackStateHistory;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Persistence\LiveComponentStatePersistence;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
use App\Framework\StateManagement\StateHistoryManager;
|
||||
use App\Framework\StateManagement\StateManager;
|
||||
|
||||
// Test State
|
||||
final readonly class TestPersistenceState implements SerializableState
|
||||
{
|
||||
public function __construct(
|
||||
public int $count = 0,
|
||||
public string $name = 'test'
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'count' => $this->count,
|
||||
'name' => $this->name,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
count: $data['count'] ?? 0,
|
||||
name: $data['name'] ?? 'test'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test Component with History
|
||||
#[TrackStateHistory(
|
||||
trackIpAddress: true,
|
||||
trackUserAgent: true,
|
||||
trackChangedProperties: true
|
||||
)]
|
||||
final readonly class TestTrackedComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public TestPersistenceState $state
|
||||
) {}
|
||||
}
|
||||
|
||||
// Test Component without History
|
||||
final readonly class TestUntrackedComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public TestPersistenceState $state
|
||||
) {}
|
||||
}
|
||||
|
||||
describe('LiveComponentStatePersistence', function () {
|
||||
beforeEach(function () {
|
||||
// Mock StateManager
|
||||
$this->stateManager = Mockery::mock(StateManager::class);
|
||||
|
||||
// Mock StateHistoryManager
|
||||
$this->historyManager = Mockery::mock(StateHistoryManager::class);
|
||||
|
||||
// Mock RequestContext
|
||||
$this->requestContext = new RequestContext(
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-456',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'Mozilla/5.0'
|
||||
);
|
||||
|
||||
// Create persistence handler
|
||||
$this->persistence = new LiveComponentStatePersistence(
|
||||
stateManager: $this->stateManager,
|
||||
historyManager: $this->historyManager,
|
||||
requestContext: $this->requestContext,
|
||||
logger: null
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
describe('persistState', function () {
|
||||
it('persists state without history when component has no TrackStateHistory', function () {
|
||||
$componentId = new ComponentId('untracked-comp', 'instance-1');
|
||||
$newState = new TestPersistenceState(count: 42, name: 'updated');
|
||||
$component = new TestUntrackedComponent($componentId, $newState);
|
||||
|
||||
// Mock: Get previous state (none exists)
|
||||
$this->stateManager
|
||||
->shouldReceive('getState')
|
||||
->once()
|
||||
->with($componentId->toString())
|
||||
->andReturn(null);
|
||||
|
||||
// Expect state to be persisted
|
||||
$this->stateManager
|
||||
->shouldReceive('setState')
|
||||
->once()
|
||||
->with($componentId->toString(), $newState);
|
||||
|
||||
// History tracking should check if enabled (returns false)
|
||||
$this->historyManager
|
||||
->shouldReceive('isHistoryEnabled')
|
||||
->once()
|
||||
->with(TestUntrackedComponent::class)
|
||||
->andReturn(false);
|
||||
|
||||
// Should NOT add history entry
|
||||
$this->historyManager
|
||||
->shouldNotReceive('addHistoryEntry');
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $newState, 'testAction');
|
||||
});
|
||||
|
||||
it('persists state with history when component has TrackStateHistory', function () {
|
||||
$componentId = new ComponentId('tracked-comp', 'instance-2');
|
||||
$previousState = new TestPersistenceState(count: 10, name: 'old');
|
||||
$newState = new TestPersistenceState(count: 42, name: 'new');
|
||||
$component = new TestTrackedComponent($componentId, $newState);
|
||||
|
||||
// Mock: Get previous state
|
||||
$this->stateManager
|
||||
->shouldReceive('getState')
|
||||
->once()
|
||||
->with($componentId->toString())
|
||||
->andReturn($previousState);
|
||||
|
||||
// Expect state to be persisted
|
||||
$this->stateManager
|
||||
->shouldReceive('setState')
|
||||
->once()
|
||||
->with($componentId->toString(), $newState);
|
||||
|
||||
// History tracking should check if enabled (returns true)
|
||||
$this->historyManager
|
||||
->shouldReceive('isHistoryEnabled')
|
||||
->once()
|
||||
->with(TestTrackedComponent::class)
|
||||
->andReturn(true);
|
||||
|
||||
// Mock: Get history for version calculation
|
||||
$this->historyManager
|
||||
->shouldReceive('getHistory')
|
||||
->once()
|
||||
->with($componentId->toString(), Mockery::any())
|
||||
->andReturn([]);
|
||||
|
||||
// Expect history entry to be added
|
||||
$this->historyManager
|
||||
->shouldReceive('addHistoryEntry')
|
||||
->once()
|
||||
->with(
|
||||
componentId: $componentId->toString(),
|
||||
stateData: json_encode($newState->toArray()),
|
||||
stateClass: TestPersistenceState::class,
|
||||
version: Mockery::any(),
|
||||
changeType: 'updated', // Previous state exists
|
||||
context: Mockery::on(function ($context) {
|
||||
return isset($context['user_id'])
|
||||
&& isset($context['session_id'])
|
||||
&& isset($context['ip_address'])
|
||||
&& isset($context['user_agent']);
|
||||
}),
|
||||
changedProperties: Mockery::on(function ($changed) {
|
||||
// Both count and name changed
|
||||
return is_array($changed) && count($changed) === 2;
|
||||
}),
|
||||
previousChecksum: Mockery::type('string'),
|
||||
currentChecksum: Mockery::type('string')
|
||||
);
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $newState, 'testAction');
|
||||
});
|
||||
|
||||
it('tracks changed properties correctly', function () {
|
||||
$componentId = new ComponentId('tracked-comp', 'instance-3');
|
||||
$previousState = new TestPersistenceState(count: 10, name: 'same');
|
||||
$newState = new TestPersistenceState(count: 42, name: 'same'); // Only count changed
|
||||
$component = new TestTrackedComponent($componentId, $newState);
|
||||
|
||||
// Mock setup
|
||||
$this->stateManager
|
||||
->shouldReceive('getState')
|
||||
->once()
|
||||
->andReturn($previousState);
|
||||
|
||||
$this->stateManager
|
||||
->shouldReceive('setState')
|
||||
->once();
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('isHistoryEnabled')
|
||||
->once()
|
||||
->andReturn(true);
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('getHistory')
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
// Expect history entry with only 'count' in changed properties
|
||||
$this->historyManager
|
||||
->shouldReceive('addHistoryEntry')
|
||||
->once()
|
||||
->with(
|
||||
componentId: Mockery::any(),
|
||||
stateData: Mockery::any(),
|
||||
stateClass: Mockery::any(),
|
||||
version: Mockery::any(),
|
||||
changeType: Mockery::any(),
|
||||
context: Mockery::any(),
|
||||
changedProperties: Mockery::on(function ($changed) {
|
||||
// Only count changed
|
||||
return is_array($changed)
|
||||
&& count($changed) === 1
|
||||
&& in_array('count', $changed);
|
||||
}),
|
||||
previousChecksum: Mockery::any(),
|
||||
currentChecksum: Mockery::any()
|
||||
);
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $newState, 'testAction');
|
||||
});
|
||||
|
||||
it('uses CREATED change type for new state', function () {
|
||||
$componentId = new ComponentId('tracked-comp', 'instance-4');
|
||||
$newState = new TestPersistenceState(count: 1, name: 'new');
|
||||
$component = new TestTrackedComponent($componentId, $newState);
|
||||
|
||||
// Mock: No previous state exists
|
||||
$this->stateManager
|
||||
->shouldReceive('getState')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
|
||||
$this->stateManager
|
||||
->shouldReceive('setState')
|
||||
->once();
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('isHistoryEnabled')
|
||||
->once()
|
||||
->andReturn(true);
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('getHistory')
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
// Expect CREATED change type
|
||||
$this->historyManager
|
||||
->shouldReceive('addHistoryEntry')
|
||||
->once()
|
||||
->with(
|
||||
componentId: Mockery::any(),
|
||||
stateData: Mockery::any(),
|
||||
stateClass: Mockery::any(),
|
||||
version: Mockery::any(),
|
||||
changeType: 'created', // New state
|
||||
context: Mockery::any(),
|
||||
changedProperties: Mockery::any(),
|
||||
previousChecksum: null, // No previous checksum
|
||||
currentChecksum: Mockery::type('string')
|
||||
);
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $newState, 'testAction');
|
||||
});
|
||||
|
||||
it('respects TrackStateHistory configuration', function () {
|
||||
// Component with selective tracking
|
||||
#[TrackStateHistory(
|
||||
trackIpAddress: false, // Disabled
|
||||
trackUserAgent: false, // Disabled
|
||||
trackChangedProperties: true
|
||||
)]
|
||||
final readonly class SelectiveComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public TestPersistenceState $state
|
||||
) {}
|
||||
}
|
||||
|
||||
$componentId = new ComponentId('selective-comp', 'instance-5');
|
||||
$newState = new TestPersistenceState(count: 1, name: 'test');
|
||||
$component = new SelectiveComponent($componentId, $newState);
|
||||
|
||||
// Mock setup
|
||||
$this->stateManager
|
||||
->shouldReceive('getState')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
|
||||
$this->stateManager
|
||||
->shouldReceive('setState')
|
||||
->once();
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('isHistoryEnabled')
|
||||
->once()
|
||||
->andReturn(true);
|
||||
|
||||
$this->historyManager
|
||||
->shouldReceive('getHistory')
|
||||
->once()
|
||||
->andReturn([]);
|
||||
|
||||
// Expect context WITHOUT ip_address and user_agent
|
||||
$this->historyManager
|
||||
->shouldReceive('addHistoryEntry')
|
||||
->once()
|
||||
->with(
|
||||
componentId: Mockery::any(),
|
||||
stateData: Mockery::any(),
|
||||
stateClass: Mockery::any(),
|
||||
version: Mockery::any(),
|
||||
changeType: Mockery::any(),
|
||||
context: Mockery::on(function ($context) {
|
||||
// Should have user_id and session_id, but NOT ip_address or user_agent
|
||||
return isset($context['user_id'])
|
||||
&& isset($context['session_id'])
|
||||
&& !isset($context['ip_address'])
|
||||
&& !isset($context['user_agent']);
|
||||
}),
|
||||
changedProperties: Mockery::any(),
|
||||
previousChecksum: Mockery::any(),
|
||||
currentChecksum: Mockery::any()
|
||||
);
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $newState, 'testAction');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
describe('Poll Attribute', function () {
|
||||
it('creates poll with default values', function () {
|
||||
$poll = new Poll();
|
||||
|
||||
expect($poll->interval)->toBe(1000);
|
||||
expect($poll->enabled)->toBeTrue();
|
||||
expect($poll->event)->toBeNull();
|
||||
expect($poll->stopOnError)->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates poll with custom values', function () {
|
||||
$poll = new Poll(
|
||||
interval: 5000,
|
||||
enabled: false,
|
||||
event: 'test.event',
|
||||
stopOnError: true
|
||||
);
|
||||
|
||||
expect($poll->interval)->toBe(5000);
|
||||
expect($poll->enabled)->toBeFalse();
|
||||
expect($poll->event)->toBe('test.event');
|
||||
expect($poll->stopOnError)->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates minimum interval', function () {
|
||||
new Poll(interval: 50);
|
||||
})->throws(
|
||||
InvalidArgumentException::class,
|
||||
'Poll interval must be at least 100ms'
|
||||
);
|
||||
|
||||
it('validates maximum interval', function () {
|
||||
new Poll(interval: 400000);
|
||||
})->throws(
|
||||
InvalidArgumentException::class,
|
||||
'Poll interval cannot exceed 5 minutes'
|
||||
);
|
||||
|
||||
it('accepts minimum valid interval', function () {
|
||||
$poll = new Poll(interval: 100);
|
||||
expect($poll->interval)->toBe(100);
|
||||
});
|
||||
|
||||
it('accepts maximum valid interval', function () {
|
||||
$poll = new Poll(interval: 300000);
|
||||
expect($poll->interval)->toBe(300000);
|
||||
});
|
||||
|
||||
it('returns interval as Duration', function () {
|
||||
$poll = new Poll(interval: 2500);
|
||||
$duration = $poll->getInterval();
|
||||
|
||||
expect($duration)->toBeInstanceOf(Duration::class);
|
||||
expect($duration->toMilliseconds())->toBe(2500);
|
||||
expect($duration->toSeconds())->toBe(2.5);
|
||||
});
|
||||
|
||||
it('creates new instance with different enabled state', function () {
|
||||
$poll = new Poll(interval: 1000, enabled: true);
|
||||
$disabled = $poll->withEnabled(false);
|
||||
|
||||
expect($poll->enabled)->toBeTrue();
|
||||
expect($disabled->enabled)->toBeFalse();
|
||||
expect($disabled->interval)->toBe($poll->interval);
|
||||
});
|
||||
|
||||
it('creates new instance with different interval', function () {
|
||||
$poll = new Poll(interval: 1000);
|
||||
$faster = $poll->withInterval(500);
|
||||
|
||||
expect($poll->interval)->toBe(1000);
|
||||
expect($faster->interval)->toBe(500);
|
||||
});
|
||||
|
||||
it('is readonly and immutable', function () {
|
||||
$poll = new Poll(interval: 1000);
|
||||
|
||||
expect($poll)->toBeInstanceOf(Poll::class);
|
||||
|
||||
// Verify readonly - should not be able to modify
|
||||
$reflection = new ReflectionClass($poll);
|
||||
expect($reflection->isReadOnly())->toBeTrue();
|
||||
});
|
||||
});
|
||||
194
tests/Unit/Framework/LiveComponents/Polling/PollServiceTest.php
Normal file
194
tests/Unit/Framework/LiveComponents/Polling/PollServiceTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\LiveComponents\Polling\PollService;
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
use App\Framework\Discovery\ValueObjects\AttributeTarget;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\DI\Container;
|
||||
|
||||
describe('PollService', function () {
|
||||
beforeEach(function () {
|
||||
// Create mock container
|
||||
$this->container = Mockery::mock(Container::class);
|
||||
|
||||
// Create attribute registry with test poll
|
||||
$this->attributeRegistry = new AttributeRegistry();
|
||||
|
||||
$this->testPoll = new DiscoveredAttribute(
|
||||
className: ClassName::create('App\\Test\\TestComponent'),
|
||||
attributeClass: Poll::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('checkData'),
|
||||
arguments: [
|
||||
'interval' => 2000,
|
||||
'enabled' => true,
|
||||
'event' => 'test.checked',
|
||||
'stopOnError' => false
|
||||
]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(Poll::class, $this->testPoll);
|
||||
|
||||
// Create discovery registry
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: $this->attributeRegistry
|
||||
);
|
||||
|
||||
// Create service
|
||||
$this->pollService = new PollService(
|
||||
$this->discoveryRegistry,
|
||||
$this->container
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('finds all polls from discovery registry', function () {
|
||||
$polls = $this->pollService->getAllPolls();
|
||||
|
||||
expect($polls)->toHaveCount(1);
|
||||
expect($polls[0]['poll'])->toBeInstanceOf(Poll::class);
|
||||
expect($polls[0]['discovered'])->toBeInstanceOf(DiscoveredAttribute::class);
|
||||
});
|
||||
|
||||
it('reconstructs poll attribute from arguments', function () {
|
||||
$polls = $this->pollService->getAllPolls();
|
||||
$poll = $polls[0]['poll'];
|
||||
|
||||
expect($poll->interval)->toBe(2000);
|
||||
expect($poll->enabled)->toBeTrue();
|
||||
expect($poll->event)->toBe('test.checked');
|
||||
expect($poll->stopOnError)->toBeFalse();
|
||||
});
|
||||
|
||||
it('gets polls for specific class', function () {
|
||||
$polls = $this->pollService->getPollsForClass('App\\Test\\TestComponent');
|
||||
|
||||
expect($polls)->toHaveCount(1);
|
||||
expect($polls[0]['method'])->toBe('checkData');
|
||||
expect($polls[0]['poll']->interval)->toBe(2000);
|
||||
});
|
||||
|
||||
it('returns empty array for class without polls', function () {
|
||||
$polls = $this->pollService->getPollsForClass('App\\Test\\NonExistent');
|
||||
|
||||
expect($polls)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('finds specific poll by class and method', function () {
|
||||
$poll = $this->pollService->findPoll(
|
||||
'App\\Test\\TestComponent',
|
||||
'checkData'
|
||||
);
|
||||
|
||||
expect($poll)->toBeInstanceOf(Poll::class);
|
||||
expect($poll->interval)->toBe(2000);
|
||||
});
|
||||
|
||||
it('returns null for non-existent poll', function () {
|
||||
$poll = $this->pollService->findPoll(
|
||||
'App\\Test\\TestComponent',
|
||||
'nonExistentMethod'
|
||||
);
|
||||
|
||||
expect($poll)->toBeNull();
|
||||
});
|
||||
|
||||
it('checks if method is pollable', function () {
|
||||
expect($this->pollService->isPollable(
|
||||
'App\\Test\\TestComponent',
|
||||
'checkData'
|
||||
))->toBeTrue();
|
||||
|
||||
expect($this->pollService->isPollable(
|
||||
'App\\Test\\TestComponent',
|
||||
'nonExistentMethod'
|
||||
))->toBeFalse();
|
||||
});
|
||||
|
||||
it('counts total polls', function () {
|
||||
expect($this->pollService->getPollCount())->toBe(1);
|
||||
});
|
||||
|
||||
it('gets only enabled polls', function () {
|
||||
// Add disabled poll
|
||||
$disabledPoll = new DiscoveredAttribute(
|
||||
className: ClassName::create('App\\Test\\DisabledComponent'),
|
||||
attributeClass: Poll::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('disabledMethod'),
|
||||
arguments: [
|
||||
'interval' => 1000,
|
||||
'enabled' => false
|
||||
]
|
||||
);
|
||||
|
||||
$this->attributeRegistry->add(Poll::class, $disabledPoll);
|
||||
|
||||
$enabledPolls = $this->pollService->getEnabledPolls();
|
||||
|
||||
expect($enabledPolls)->toHaveCount(1);
|
||||
expect($enabledPolls[0]['poll']->enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('executes poll method via container', function () {
|
||||
$mockComponent = new class {
|
||||
public function checkData(): array
|
||||
{
|
||||
return ['status' => 'ok'];
|
||||
}
|
||||
};
|
||||
|
||||
$this->container->shouldReceive('get')
|
||||
->with('App\\Test\\TestComponent')
|
||||
->andReturn($mockComponent);
|
||||
|
||||
$result = $this->pollService->executePoll(
|
||||
'App\\Test\\TestComponent',
|
||||
'checkData'
|
||||
);
|
||||
|
||||
expect($result)->toBe(['status' => 'ok']);
|
||||
});
|
||||
|
||||
it('throws exception for non-existent method', function () {
|
||||
$mockComponent = new class {
|
||||
// No checkData method
|
||||
};
|
||||
|
||||
$this->container->shouldReceive('get')
|
||||
->with('App\\Test\\TestComponent')
|
||||
->andReturn($mockComponent);
|
||||
|
||||
$this->pollService->executePoll(
|
||||
'App\\Test\\TestComponent',
|
||||
'checkData'
|
||||
);
|
||||
})->throws(BadMethodCallException::class);
|
||||
|
||||
it('wraps execution errors', function () {
|
||||
$mockComponent = new class {
|
||||
public function checkData(): array
|
||||
{
|
||||
throw new RuntimeException('Internal error');
|
||||
}
|
||||
};
|
||||
|
||||
$this->container->shouldReceive('get')
|
||||
->with('App\\Test\\TestComponent')
|
||||
->andReturn($mockComponent);
|
||||
|
||||
$this->pollService->executePoll(
|
||||
'App\\Test\\TestComponent',
|
||||
'checkData'
|
||||
);
|
||||
})->throws(RuntimeException::class, 'Failed to execute poll');
|
||||
});
|
||||
Reference in New Issue
Block a user