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