feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});

View 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');
});