479 lines
16 KiB
PHP
479 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Cache\Cache;
|
|
use App\Framework\Cache\CacheKey;
|
|
use App\Framework\Cache\SmartCache;
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\Core\ValueObjects\Timestamp;
|
|
use App\Framework\Database\EntityManager;
|
|
use App\Framework\StateManagement\Database\ComponentStateEntity;
|
|
use App\Framework\StateManagement\Database\DatabaseStateManager;
|
|
use App\Framework\StateManagement\SerializableState;
|
|
|
|
// Test State Value Object
|
|
final readonly class TestComponentState implements SerializableState
|
|
{
|
|
public function __construct(
|
|
public int $count = 0,
|
|
public string $message = 'test'
|
|
) {}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'count' => $this->count,
|
|
'message' => $this->message,
|
|
];
|
|
}
|
|
|
|
public static function fromArray(array $data): self
|
|
{
|
|
return new self(
|
|
count: $data['count'] ?? 0,
|
|
message: $data['message'] ?? 'test'
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('DatabaseStateManager', 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;
|
|
|
|
// Real Cache for testing
|
|
$this->cache = new SmartCache();
|
|
|
|
// Create DatabaseStateManager
|
|
$this->stateManager = new DatabaseStateManager(
|
|
entityManager: $this->entityManager,
|
|
cache: $this->cache,
|
|
stateClass: TestComponentState::class,
|
|
logger: null,
|
|
cacheTtl: Duration::fromSeconds(60)
|
|
);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|
|
|
|
describe('setState', function () {
|
|
it('creates new state entity when none exists', function () {
|
|
$key = 'test-component:1';
|
|
$state = new TestComponentState(count: 42, message: 'hello');
|
|
|
|
// Mock: Entity does not exist
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn(null);
|
|
|
|
// Expect persist and commit
|
|
$this->unitOfWork
|
|
->shouldReceive('persist')
|
|
->once()
|
|
->with(Mockery::type(ComponentStateEntity::class));
|
|
|
|
$this->unitOfWork
|
|
->shouldReceive('commit')
|
|
->once();
|
|
|
|
// Set state
|
|
$this->stateManager->setState($key, $state);
|
|
|
|
// Verify cache was populated
|
|
$cached = $this->cache->get(CacheKey::from("component_state:{$key}"));
|
|
expect($cached->isHit)->toBeTrue();
|
|
});
|
|
|
|
it('updates existing state entity', function () {
|
|
$key = 'test-component:2';
|
|
$state = new TestComponentState(count: 100, message: 'updated');
|
|
|
|
// Mock: Entity exists
|
|
$existingEntity = new ComponentStateEntity(
|
|
componentId: $key,
|
|
stateData: json_encode(['count' => 50, 'message' => 'old']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'old-checksum',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
);
|
|
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($existingEntity);
|
|
|
|
// Expect persist with updated entity
|
|
$this->unitOfWork
|
|
->shouldReceive('persist')
|
|
->once()
|
|
->with(Mockery::on(function (ComponentStateEntity $entity) {
|
|
return $entity->version === 2;
|
|
}));
|
|
|
|
$this->unitOfWork
|
|
->shouldReceive('commit')
|
|
->once();
|
|
|
|
// Set state
|
|
$this->stateManager->setState($key, $state);
|
|
});
|
|
});
|
|
|
|
describe('getState', function () {
|
|
it('returns state from cache on hit', function () {
|
|
$key = 'test-component:3';
|
|
$state = new TestComponentState(count: 77, message: 'cached');
|
|
|
|
// Populate cache
|
|
$stateData = json_encode($state->toArray());
|
|
$this->cache->set(
|
|
CacheKey::from("component_state:{$key}"),
|
|
$stateData,
|
|
Duration::fromSeconds(60)
|
|
);
|
|
|
|
// Should NOT call EntityManager
|
|
$this->entityManager->shouldNotReceive('find');
|
|
|
|
// Get state
|
|
$result = $this->stateManager->getState($key);
|
|
|
|
expect($result)->toBeInstanceOf(TestComponentState::class);
|
|
expect($result->count)->toBe(77);
|
|
expect($result->message)->toBe('cached');
|
|
});
|
|
|
|
it('falls back to database on cache miss', function () {
|
|
$key = 'test-component:4';
|
|
$state = new TestComponentState(count: 88, message: 'database');
|
|
|
|
// Mock: Entity exists in database
|
|
$entity = new ComponentStateEntity(
|
|
componentId: $key,
|
|
stateData: json_encode($state->toArray()),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: hash('sha256', json_encode($state->toArray())),
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
);
|
|
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($entity);
|
|
|
|
// Get state
|
|
$result = $this->stateManager->getState($key);
|
|
|
|
expect($result)->toBeInstanceOf(TestComponentState::class);
|
|
expect($result->count)->toBe(88);
|
|
expect($result->message)->toBe('database');
|
|
|
|
// Cache should be populated
|
|
$cached = $this->cache->get(CacheKey::from("component_state:{$key}"));
|
|
expect($cached->isHit)->toBeTrue();
|
|
});
|
|
|
|
it('returns null when state does not exist', function () {
|
|
$key = 'test-component:nonexistent';
|
|
|
|
// Mock: Entity does not exist
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn(null);
|
|
|
|
// Get state
|
|
$result = $this->stateManager->getState($key);
|
|
|
|
expect($result)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('hasState', function () {
|
|
it('returns true when state exists in cache', function () {
|
|
$key = 'test-component:5';
|
|
$state = new TestComponentState(count: 99, message: 'exists');
|
|
|
|
// Populate cache
|
|
$this->cache->set(
|
|
CacheKey::from("component_state:{$key}"),
|
|
json_encode($state->toArray()),
|
|
Duration::fromSeconds(60)
|
|
);
|
|
|
|
// Should NOT call EntityManager
|
|
$this->entityManager->shouldNotReceive('find');
|
|
|
|
// Check existence
|
|
$result = $this->stateManager->hasState($key);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('checks database when not in cache', function () {
|
|
$key = 'test-component:6';
|
|
|
|
// Mock: Entity exists in database
|
|
$entity = new ComponentStateEntity(
|
|
componentId: $key,
|
|
stateData: json_encode(['count' => 1, 'message' => 'test']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'checksum',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
);
|
|
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($entity);
|
|
|
|
// Check existence
|
|
$result = $this->stateManager->hasState($key);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('returns false when state does not exist', function () {
|
|
$key = 'test-component:nonexistent';
|
|
|
|
// Mock: Entity does not exist
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn(null);
|
|
|
|
// Check existence
|
|
$result = $this->stateManager->hasState($key);
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('removeState', function () {
|
|
it('removes state from database and cache', function () {
|
|
$key = 'test-component:7';
|
|
|
|
// Populate cache
|
|
$this->cache->set(
|
|
CacheKey::from("component_state:{$key}"),
|
|
json_encode(['count' => 1, 'message' => 'test']),
|
|
Duration::fromSeconds(60)
|
|
);
|
|
|
|
// Mock: Entity exists
|
|
$entity = new ComponentStateEntity(
|
|
componentId: $key,
|
|
stateData: json_encode(['count' => 1, 'message' => 'test']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'checksum',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
);
|
|
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($entity);
|
|
|
|
// Expect remove and commit
|
|
$this->unitOfWork
|
|
->shouldReceive('remove')
|
|
->once()
|
|
->with($entity);
|
|
|
|
$this->unitOfWork
|
|
->shouldReceive('commit')
|
|
->once();
|
|
|
|
// Remove state
|
|
$this->stateManager->removeState($key);
|
|
|
|
// Cache should be cleared
|
|
$cached = $this->cache->get(CacheKey::from("component_state:{$key}"));
|
|
expect($cached->isHit)->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('updateState', function () {
|
|
it('atomically updates state with updater function', function () {
|
|
$key = 'test-component:8';
|
|
$initialState = new TestComponentState(count: 10, message: 'initial');
|
|
|
|
// Mock: Get current state
|
|
$entity = new ComponentStateEntity(
|
|
componentId: $key,
|
|
stateData: json_encode($initialState->toArray()),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: hash('sha256', json_encode($initialState->toArray())),
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
);
|
|
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($entity);
|
|
|
|
// Expect persist with updated state
|
|
$this->entityManager
|
|
->shouldReceive('find')
|
|
->once()
|
|
->with(ComponentStateEntity::class, $key)
|
|
->andReturn($entity);
|
|
|
|
$this->unitOfWork
|
|
->shouldReceive('persist')
|
|
->once()
|
|
->with(Mockery::type(ComponentStateEntity::class));
|
|
|
|
$this->unitOfWork
|
|
->shouldReceive('commit')
|
|
->once();
|
|
|
|
// Update state
|
|
$result = $this->stateManager->updateState(
|
|
$key,
|
|
fn(TestComponentState $state) => new TestComponentState(
|
|
count: $state->count + 5,
|
|
message: 'updated'
|
|
)
|
|
);
|
|
|
|
expect($result)->toBeInstanceOf(TestComponentState::class);
|
|
expect($result->count)->toBe(15);
|
|
expect($result->message)->toBe('updated');
|
|
});
|
|
});
|
|
|
|
describe('getAllStates', function () {
|
|
it('returns all states for state class', function () {
|
|
// Mock: Multiple entities
|
|
$entities = [
|
|
new ComponentStateEntity(
|
|
componentId: 'test-component:1',
|
|
stateData: json_encode(['count' => 1, 'message' => 'first']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'checksum1',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
),
|
|
new ComponentStateEntity(
|
|
componentId: 'test-component:2',
|
|
stateData: json_encode(['count' => 2, 'message' => 'second']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'checksum2',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
),
|
|
];
|
|
|
|
$this->entityManager
|
|
->shouldReceive('findBy')
|
|
->once()
|
|
->with(
|
|
ComponentStateEntity::class,
|
|
['state_class' => TestComponentState::class]
|
|
)
|
|
->andReturn($entities);
|
|
|
|
// Get all states
|
|
$result = $this->stateManager->getAllStates();
|
|
|
|
expect($result)->toBeArray();
|
|
expect($result)->toHaveCount(2);
|
|
expect($result['test-component:1'])->toBeInstanceOf(TestComponentState::class);
|
|
expect($result['test-component:1']->count)->toBe(1);
|
|
expect($result['test-component:2']->count)->toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getStatistics', function () {
|
|
it('returns statistics about state storage', function () {
|
|
// Mock: Multiple entities
|
|
$entities = [
|
|
new ComponentStateEntity(
|
|
componentId: 'test-component:1',
|
|
stateData: json_encode(['count' => 1, 'message' => 'test']),
|
|
stateClass: TestComponentState::class,
|
|
componentName: 'test-component',
|
|
userId: null,
|
|
sessionId: null,
|
|
version: 1,
|
|
checksum: 'checksum',
|
|
createdAt: Timestamp::now(),
|
|
updatedAt: Timestamp::now(),
|
|
expiresAt: null
|
|
),
|
|
];
|
|
|
|
$this->entityManager
|
|
->shouldReceive('findBy')
|
|
->once()
|
|
->with(
|
|
ComponentStateEntity::class,
|
|
['state_class' => TestComponentState::class]
|
|
)
|
|
->andReturn($entities);
|
|
|
|
// Get statistics
|
|
$stats = $this->stateManager->getStatistics();
|
|
|
|
expect($stats->totalKeys)->toBe(1);
|
|
expect($stats->hitCount)->toBeInt();
|
|
expect($stats->missCount)->toBeInt();
|
|
});
|
|
});
|
|
});
|