Files
michaelschiemer/tests/Unit/Framework/StateManagement/Database/DatabaseStateHistoryManagerTest.php

455 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\EntityManager;
use App\Framework\LiveComponents\Attributes\TrackStateHistory;
use App\Framework\StateManagement\Database\DatabaseStateHistoryManager;
use App\Framework\StateManagement\Database\StateChangeType;
use App\Framework\StateManagement\Database\StateHistoryEntry;
// Test Component with TrackStateHistory
#[TrackStateHistory(
trackIpAddress: true,
trackUserAgent: true,
trackChangedProperties: true
)]
final readonly class TestHistoryComponent
{
public function __construct(
public string $id
) {}
}
// Test Component without TrackStateHistory
final readonly class TestNoHistoryComponent
{
public function __construct(
public string $id
) {}
}
describe('DatabaseStateHistoryManager', function () {
beforeEach(function () {
// Mock EntityManager
$this->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();
});
});
});