entityManager = Mockery::mock(EntityManager::class); $this->unitOfWork = Mockery::mock(\App\Framework\Database\UnitOfWork::class); $this->entityManager->unitOfWork = $this->unitOfWork; // Create DatabaseStateHistoryManager $this->historyManager = new DatabaseStateHistoryManager( entityManager: $this->entityManager, logger: null ); }); afterEach(function () { Mockery::close(); }); describe('addHistoryEntry', function () { it('adds history entry to database', function () { $componentId = 'test-component:1'; $stateData = json_encode(['count' => 42, 'message' => 'test']); $stateClass = 'TestComponentState'; $version = 1; $changeType = StateChangeType::CREATED->value; $context = [ 'user_id' => 'user-123', 'session_id' => 'session-456', 'ip_address' => '127.0.0.1', 'user_agent' => 'Mozilla/5.0' ]; $changedProperties = ['count', 'message']; $currentChecksum = hash('sha256', $stateData); // Expect persist and commit $this->unitOfWork ->shouldReceive('persist') ->once() ->with(Mockery::type(StateHistoryEntry::class)); $this->unitOfWork ->shouldReceive('commit') ->once(); // Add history entry $this->historyManager->addHistoryEntry( componentId: $componentId, stateData: $stateData, stateClass: $stateClass, version: $version, changeType: $changeType, context: $context, changedProperties: $changedProperties, previousChecksum: null, currentChecksum: $currentChecksum ); }); }); describe('getHistory', function () { it('returns history entries ordered by created_at DESC', function () { $componentId = 'test-component:2'; // Mock: History entries $entries = [ new StateHistoryEntry( id: 2, componentId: $componentId, stateData: json_encode(['count' => 10]), stateClass: 'TestState', version: 2, changeType: StateChangeType::UPDATED, changedProperties: ['count'], userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: 'checksum1', currentChecksum: 'checksum2', createdAt: Timestamp::now() ), new StateHistoryEntry( id: 1, componentId: $componentId, stateData: json_encode(['count' => 0]), stateClass: 'TestState', version: 1, changeType: StateChangeType::CREATED, changedProperties: null, userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: null, currentChecksum: 'checksum1', createdAt: Timestamp::now() ), ]; $this->entityManager ->shouldReceive('findBy') ->once() ->with( StateHistoryEntry::class, ['component_id' => $componentId], ['created_at' => 'DESC'], 100, 0 ) ->andReturn($entries); // Get history $result = $this->historyManager->getHistory($componentId, limit: 100); expect($result)->toBeArray(); expect($result)->toHaveCount(2); expect($result[0]->version)->toBe(2); expect($result[1]->version)->toBe(1); }); }); describe('getHistoryByVersion', function () { it('returns specific version from history', function () { $componentId = 'test-component:3'; $version = 5; // Mock: History entry $entry = new StateHistoryEntry( id: 5, componentId: $componentId, stateData: json_encode(['count' => 50]), stateClass: 'TestState', version: $version, changeType: StateChangeType::UPDATED, changedProperties: ['count'], userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: 'checksum4', currentChecksum: 'checksum5', createdAt: Timestamp::now() ); $this->entityManager ->shouldReceive('findBy') ->once() ->with( StateHistoryEntry::class, [ 'component_id' => $componentId, 'version' => $version ], Mockery::any(), 1 ) ->andReturn([$entry]); // Get specific version $result = $this->historyManager->getHistoryByVersion($componentId, $version); expect($result)->toBeInstanceOf(StateHistoryEntry::class); expect($result->version)->toBe(5); expect($result->componentId)->toBe($componentId); }); it('returns null when version does not exist', function () { $componentId = 'test-component:4'; $version = 999; $this->entityManager ->shouldReceive('findBy') ->once() ->andReturn([]); // Get non-existent version $result = $this->historyManager->getHistoryByVersion($componentId, $version); expect($result)->toBeNull(); }); }); describe('getHistoryByUser', function () { it('returns history entries for specific user', function () { $userId = 'user-123'; // Mock: User's history entries $entries = [ new StateHistoryEntry( id: 1, componentId: 'comp-1', stateData: json_encode(['data' => 'test']), stateClass: 'TestState', version: 1, changeType: StateChangeType::CREATED, changedProperties: null, userId: $userId, sessionId: 'session-1', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: null, currentChecksum: 'checksum1', createdAt: Timestamp::now() ), ]; $this->entityManager ->shouldReceive('findBy') ->once() ->with( StateHistoryEntry::class, ['user_id' => $userId], ['created_at' => 'DESC'], 100 ) ->andReturn($entries); // Get user history $result = $this->historyManager->getHistoryByUser($userId); expect($result)->toBeArray(); expect($result)->toHaveCount(1); expect($result[0]->userId)->toBe($userId); }); }); describe('cleanup', function () { it('deletes old entries keeping only last N', function () { $componentId = 'test-component:5'; $keepLast = 2; // Mock: 5 entries, we keep 2, delete 3 $entries = array_map( fn(int $i) => new StateHistoryEntry( id: $i, componentId: $componentId, stateData: json_encode(['version' => $i]), stateClass: 'TestState', version: $i, changeType: StateChangeType::UPDATED, changedProperties: null, userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: "checksum{$i}", currentChecksum: "checksum" . ($i + 1), createdAt: Timestamp::now() ), range(5, 1, -1) // DESC order ); $this->entityManager ->shouldReceive('findBy') ->once() ->with( StateHistoryEntry::class, ['component_id' => $componentId], ['created_at' => 'DESC'] ) ->andReturn($entries); // Expect remove for 3 oldest entries $this->unitOfWork ->shouldReceive('remove') ->times(3) ->with(Mockery::type(StateHistoryEntry::class)); $this->unitOfWork ->shouldReceive('commit') ->once(); // Cleanup $deletedCount = $this->historyManager->cleanup($componentId, $keepLast); expect($deletedCount)->toBe(3); }); }); describe('deleteHistory', function () { it('deletes all history for component', function () { $componentId = 'test-component:6'; // Mock: 3 entries to delete $entries = array_map( fn(int $i) => new StateHistoryEntry( id: $i, componentId: $componentId, stateData: json_encode(['data' => 'test']), stateClass: 'TestState', version: $i, changeType: StateChangeType::UPDATED, changedProperties: null, userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: "checksum{$i}", currentChecksum: "checksum" . ($i + 1), createdAt: Timestamp::now() ), range(1, 3) ); $this->entityManager ->shouldReceive('findBy') ->once() ->with( StateHistoryEntry::class, ['component_id' => $componentId] ) ->andReturn($entries); // Expect remove for all entries $this->unitOfWork ->shouldReceive('remove') ->times(3) ->with(Mockery::type(StateHistoryEntry::class)); $this->unitOfWork ->shouldReceive('commit') ->once(); // Delete history $deletedCount = $this->historyManager->deleteHistory($componentId); expect($deletedCount)->toBe(3); }); }); describe('isHistoryEnabled', function () { it('returns true when component has TrackStateHistory attribute', function () { $result = $this->historyManager->isHistoryEnabled(TestHistoryComponent::class); expect($result)->toBeTrue(); }); it('returns false when component does not have TrackStateHistory attribute', function () { $result = $this->historyManager->isHistoryEnabled(TestNoHistoryComponent::class); expect($result)->toBeFalse(); }); it('returns false when class does not exist', function () { $result = $this->historyManager->isHistoryEnabled('NonExistentClass'); expect($result)->toBeFalse(); }); }); describe('getStatistics', function () { it('returns statistics about history storage', function () { // Mock: Multiple entries $entries = [ new StateHistoryEntry( id: 1, componentId: 'comp-1', stateData: json_encode(['data' => 'test']), stateClass: 'TestState', version: 1, changeType: StateChangeType::CREATED, changedProperties: null, userId: 'user-123', sessionId: 'session-456', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: null, currentChecksum: 'checksum1', createdAt: Timestamp::fromString('2024-01-01 10:00:00') ), new StateHistoryEntry( id: 2, componentId: 'comp-2', stateData: json_encode(['data' => 'test']), stateClass: 'TestState', version: 1, changeType: StateChangeType::CREATED, changedProperties: null, userId: 'user-456', sessionId: 'session-789', ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0', previousChecksum: null, currentChecksum: 'checksum2', createdAt: Timestamp::fromString('2024-01-02 10:00:00') ), ]; $this->entityManager ->shouldReceive('findBy') ->once() ->with(StateHistoryEntry::class, []) ->andReturn($entries); // Get statistics $stats = $this->historyManager->getStatistics(); expect($stats)->toBeArray(); expect($stats['total_entries'])->toBe(2); expect($stats['total_components'])->toBe(2); expect($stats['oldest_entry'])->toBeInstanceOf(Timestamp::class); expect($stats['newest_entry'])->toBeInstanceOf(Timestamp::class); }); it('returns empty statistics when no entries exist', function () { $this->entityManager ->shouldReceive('findBy') ->once() ->with(StateHistoryEntry::class, []) ->andReturn([]); // Get statistics $stats = $this->historyManager->getStatistics(); expect($stats)->toBeArray(); expect($stats['total_entries'])->toBe(0); expect($stats['total_components'])->toBe(0); expect($stats['oldest_entry'])->toBeNull(); expect($stats['newest_entry'])->toBeNull(); }); }); });