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

@@ -13,6 +13,8 @@ use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorAggregation\Storage\InMemoryErrorStorage;
use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ErrorHandling\ErrorHandlerRegistry;
use App\Framework\ErrorReporting\ErrorReporter;
use App\Framework\ErrorReporting\ErrorReporterInterface;
use App\Framework\ErrorReporting\Storage\InMemoryErrorReportStorage;
@@ -25,11 +27,19 @@ use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\ResponseEmitter;
use App\Framework\DI\DefaultContainer;
use App\Framework\Queue\InMemoryQueue;
use App\Framework\Logging\Logger;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\InMemoryLogger;
describe('ErrorHandler Full Pipeline Integration', function () {
beforeEach(function () {
// Create all dependencies
$this->container = new DefaultContainer();
// Create and bind InMemoryLogger for testing
$this->logger = new InMemoryLogger();
$this->container->bind(Logger::class, fn() => $this->logger);
$this->emitter = new ResponseEmitter();
$this->requestIdGenerator = new RequestIdGenerator();
@@ -110,7 +120,7 @@ describe('ErrorHandler Full Pipeline Integration', function () {
cache: $this->cache,
clock: $this->clock,
alertQueue: $this->alertQueue,
logger: null,
logger: $this->logger,
batchSize: 100,
maxRetentionDays: 90
);
@@ -122,13 +132,17 @@ describe('ErrorHandler Full Pipeline Integration', function () {
$this->errorReporter = new ErrorReporter(
storage: $this->errorReportStorage,
clock: $this->clock,
logger: null,
logger: $this->logger,
queue: $this->reportQueue,
asyncProcessing: false, // Synchronous for testing
processors: [],
filters: []
);
// Create ErrorHandlerManager
$registry = new ErrorHandlerRegistry();
$this->handlerManager = new ErrorHandlerManager($registry);
// Create ErrorHandler with full pipeline
$this->errorHandler = new ErrorHandler(
emitter: $this->emitter,
@@ -136,7 +150,8 @@ describe('ErrorHandler Full Pipeline Integration', function () {
requestIdGenerator: $this->requestIdGenerator,
errorAggregator: $this->errorAggregator,
errorReporter: $this->errorReporter,
logger: null,
handlerManager: $this->handlerManager,
logger: $this->logger,
isDebugMode: true,
securityHandler: null
);

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\AnalyticsCollector;
use App\Framework\Analytics\Storage\AnalyticsStorage;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Random\RandomGenerator;
describe('AnalyticsCollector', function () {
beforeEach(function () {
// Mock dependencies
$this->performanceCollector = Mockery::mock(PerformanceCollectorInterface::class);
$this->storage = Mockery::mock(AnalyticsStorage::class);
$this->random = Mockery::mock(RandomGenerator::class);
// Create real ServerEnvironment with test data (final class, can't be mocked)
$this->serverEnvironment = new ServerEnvironment([
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_USER_AGENT' => 'Test-Agent/1.0',
'REQUEST_URI' => '/test',
'HTTP_REFERER' => 'https://example.com',
]);
// Allow any performance collector calls (framework internal)
$this->performanceCollector
->shouldReceive('recordMetric')
->zeroOrMoreTimes();
$this->performanceCollector
->shouldReceive('increment')
->zeroOrMoreTimes();
// Allow storage aggregated calls (framework internal)
$this->storage
->shouldReceive('storeAggregated')
->zeroOrMoreTimes();
// Allow random float calls for sampling (may or may not be called)
$this->random
->shouldReceive('float')
->with(0, 1)
->zeroOrMoreTimes()
->andReturn(0.5);
// Allow random bytes calls for session ID generation (may or may not be called)
$this->random
->shouldReceive('bytes')
->with(16)
->zeroOrMoreTimes()
->andReturn(str_repeat('a', 16));
// Default: tracking enabled, 100% sampling for tests
$this->collector = new AnalyticsCollector(
performanceCollector: $this->performanceCollector,
storage: $this->storage,
random: $this->random,
serverEnvironment: $this->serverEnvironment,
enabled: true,
samplingRate: 1.0
);
});
afterEach(function () {
Mockery::close();
});
describe('trackAction', function () {
it('tracks user action with category and properties', function () {
// Expect raw data storage with flexible array matcher
$this->storage
->shouldReceive('storeRawData')
->once()
->with(
Mockery::on(function ($data) {
return is_array($data)
&& $data['category'] === 'user_behavior'
&& $data['action'] === 'button_click'
&& isset($data['session_id'])
&& isset($data['timestamp'])
&& isset($data['button_id'])
&& $data['button_id'] === 'submit-btn';
}),
1.0
);
// Track action
$this->collector->trackAction(
action: 'button_click',
category: AnalyticsCategory::USER_BEHAVIOR,
properties: ['button_id' => 'submit-btn']
);
});
it('does not track when analytics disabled', function () {
// Create collector with analytics disabled
$disabledCollector = new AnalyticsCollector(
performanceCollector: $this->performanceCollector,
storage: $this->storage,
random: $this->random,
serverEnvironment: $this->serverEnvironment,
enabled: false, // Disabled
samplingRate: 1.0
);
// Storage should NOT be called
$this->storage->shouldNotReceive('storeRawData');
// Track action (should be ignored)
$disabledCollector->trackAction('click', AnalyticsCategory::USER_BEHAVIOR);
});
it('respects sampling rate', function () {
// Create new Random mock for this test
$randomMock = Mockery::mock(RandomGenerator::class);
// Random returns 0.6 (above 0.5 threshold) -> should NOT track (0.6 > 0.5)
$randomMock->shouldReceive('float')->with(0, 1)->andReturn(0.6);
// Create collector with 50% sampling
$sampledCollector = new AnalyticsCollector(
performanceCollector: $this->performanceCollector,
storage: $this->storage,
random: $randomMock,
serverEnvironment: $this->serverEnvironment,
enabled: true,
samplingRate: 0.5
);
// Storage should NOT be called (sampled out)
$this->storage->shouldNotReceive('storeRawData');
// Track action (should be sampled out)
$sampledCollector->trackAction('click', AnalyticsCategory::USER_BEHAVIOR);
});
});
describe('trackPageView', function () {
it('tracks page view with path and title', function () {
// Expect raw data storage with flexible matcher
$this->storage
->shouldReceive('storeRawData')
->once()
->with(
Mockery::on(function ($data) {
return is_array($data)
&& $data['path'] === '/dashboard'
&& $data['title'] === 'Dashboard'
&& isset($data['timestamp'])
&& isset($data['session_id']);
}),
1.0
);
// Track page view
$this->collector->trackPageView(
path: '/dashboard',
title: 'Dashboard'
);
});
});
describe('trackError', function () {
it('tracks error with type and message', function () {
// trackError only logs to performance collector, not storage
// Storage expectations are handled by global mocks
// Track error
$this->collector->trackError(
errorType: 'ValidationException',
message: 'Invalid email format'
);
// Test passes if no exceptions are thrown
expect(true)->toBeTrue();
});
});
describe('trackBusinessEvent', function () {
it('tracks business event with value and currency', function () {
// trackBusinessEvent only logs to performance collector, not storage
// Storage expectations are handled by global mocks
// Track business event
$this->collector->trackBusinessEvent(
event: 'purchase_completed',
value: 99.99,
currency: 'EUR'
);
// Test passes if no exceptions are thrown
expect(true)->toBeTrue();
});
});
describe('trackApiCall', function () {
it('tracks API call with endpoint and metrics', function () {
// trackApiCall only logs to performance collector, not storage
// Storage expectations are handled by global mocks
// Track API call
$this->collector->trackApiCall(
endpoint: '/api/users',
method: 'GET',
responseCode: 200,
responseTime: 0.125
);
// Test passes if no exceptions are thrown
expect(true)->toBeTrue();
});
});
describe('edge cases', function () {
it('handles zero sampling rate', function () {
// Create new Random mock for this test
$randomMock = Mockery::mock(RandomGenerator::class);
// Random float will be called and return value > 0.0 (will fail sampling)
$randomMock->shouldReceive('float')->with(0, 1)->andReturn(0.1);
// Create collector with 0% sampling (no tracking)
$noSamplingCollector = new AnalyticsCollector(
performanceCollector: $this->performanceCollector,
storage: $this->storage,
random: $randomMock,
serverEnvironment: $this->serverEnvironment,
enabled: true,
samplingRate: 0.0
);
// Storage should NOT be called
$this->storage->shouldNotReceive('storeRawData');
// Track action (should be sampled out)
$noSamplingCollector->trackAction('click', AnalyticsCategory::USER_BEHAVIOR);
});
it('handles full sampling rate', function () {
// With 100% sampling, float() should NOT be called (early return)
// Expect storage to be called
$this->storage
->shouldReceive('storeRawData')
->once()
->with(Mockery::type('array'), 1.0);
// Track action (should be tracked)
$this->collector->trackAction('click', AnalyticsCategory::USER_BEHAVIOR);
});
});
});

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Framework\Api\ApiException;
use App\Framework\Http\Headers;
use App\Framework\Http\Status;
use App\Framework\HttpClient\ClientResponse;
describe('ApiException', function () {
it('constructs with message, code, and response', function () {
$response = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{"error": "Invalid request"}'
);
$exception = new ApiException(
message: 'API Error: Invalid request',
code: 400,
response: $response
);
expect($exception->getMessage())->toBe('API Error: Invalid request');
expect($exception->getCode())->toBe(400);
expect($exception->getResponse())->toBe($response);
});
it('returns response data as array', function () {
$response = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{"error": "Invalid request", "field": "email"}'
);
$exception = new ApiException(
message: 'API Error',
code: 400,
response: $response
);
$data = $exception->getResponseData();
expect($data)->toBeArray();
expect($data['error'])->toBe('Invalid request');
expect($data['field'])->toBe('email');
});
it('returns null for invalid JSON response', function () {
$response = new ClientResponse(
status: Status::INTERNAL_SERVER_ERROR,
headers: new Headers([]),
body: 'Invalid JSON {'
);
$exception = new ApiException(
message: 'API Error',
code: 500,
response: $response
);
expect($exception->getResponseData())->toBeNull();
});
it('returns null for empty response body', function () {
$response = new ClientResponse(
status: Status::NO_CONTENT,
headers: new Headers([]),
body: ''
);
$exception = new ApiException(
message: 'API Error',
code: 204,
response: $response
);
expect($exception->getResponseData())->toBeNull();
});
it('extends FrameworkException', function () {
$response = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{}'
);
$exception = new ApiException(
message: 'Test',
code: 400,
response: $response
);
expect($exception)->toBeInstanceOf(\App\Framework\Exception\FrameworkException::class);
});
});

View File

@@ -0,0 +1,460 @@
<?php
declare(strict_types=1);
use App\Framework\Api\ApiException;
use App\Framework\Api\ApiRequestTrait;
use App\Framework\Http\Headers;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\HttpClient\ClientOptions;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
// Test class that uses the trait
final class TestApiClient
{
use ApiRequestTrait;
public function __construct(
string $baseUrl,
HttpClient $httpClient,
ClientOptions $defaultOptions
) {
$this->baseUrl = $baseUrl;
$this->httpClient = $httpClient;
$this->defaultOptions = $defaultOptions;
}
// Expose protected method for testing
public function testDecodeJson(ClientResponse $response): array
{
return $this->decodeJson($response);
}
}
describe('ApiRequestTrait', function () {
beforeEach(function () {
$this->httpClient = Mockery::mock(HttpClient::class);
$this->defaultOptions = ClientOptions::withTimeout(30);
$this->baseUrl = 'https://api.example.com';
$this->apiClient = new TestApiClient(
$this->baseUrl,
$this->httpClient,
$this->defaultOptions
);
});
afterEach(function () {
Mockery::close();
});
describe('sendRequest', function () {
it('sends GET request successfully', function () {
$expectedResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{"data": "success"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) {
return $request instanceof ClientRequest
&& $request->method === Method::GET
&& str_contains($request->url, '/api/users');
}))
->andReturn($expectedResponse);
$response = $this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/users',
data: []
);
expect($response)->toBe($expectedResponse);
expect($response->status)->toBe(Status::OK);
});
it('sends POST request with data', function () {
$expectedResponse = new ClientResponse(
status: Status::CREATED,
headers: new Headers([]),
body: '{"id": 123}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) {
return $request instanceof ClientRequest
&& $request->method === Method::POST
&& str_contains($request->url, '/api/users')
&& !empty($request->body); // Data is stored in body as JSON
}))
->andReturn($expectedResponse);
$response = $this->apiClient->sendRequest(
method: Method::POST,
endpoint: '/api/users',
data: ['name' => 'John Doe', 'email' => 'john@example.com']
);
expect($response->status)->toBe(Status::CREATED);
});
it('strips leading slash from endpoint', function () {
$expectedResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) {
// URL should be clean without double slashes
return $request->url === 'https://api.example.com/api/test';
}))
->andReturn($expectedResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
});
it('handles endpoint without leading slash', function () {
$expectedResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) {
return $request->url === 'https://api.example.com/api/test';
}))
->andReturn($expectedResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: 'api/test'
);
});
it('uses custom options when provided', function () {
$customOptions = ClientOptions::withTimeout(60);
$expectedResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) use ($customOptions) {
return $request->options === $customOptions;
}))
->andReturn($expectedResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test',
data: [],
options: $customOptions
);
});
it('uses default options when none provided', function () {
$expectedResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{}'
);
$this->httpClient
->shouldReceive('send')
->once()
->with(Mockery::on(function ($request) {
return $request->options === $this->defaultOptions;
}))
->andReturn($expectedResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
});
});
describe('error handling', function () {
it('throws ApiException for 400 errors', function () {
$errorResponse = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{"error": "Invalid request"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
})->throws(ApiException::class);
it('throws ApiException for 401 errors', function () {
$errorResponse = new ClientResponse(
status: Status::UNAUTHORIZED,
headers: new Headers([]),
body: '{"error": "Unauthorized"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
})->throws(ApiException::class);
it('throws ApiException for 404 errors', function () {
$errorResponse = new ClientResponse(
status: Status::NOT_FOUND,
headers: new Headers([]),
body: '{"error": "Not found"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
})->throws(ApiException::class);
it('throws ApiException for 500 errors', function () {
$errorResponse = new ClientResponse(
status: Status::INTERNAL_SERVER_ERROR,
headers: new Headers([]),
body: '{"error": "Server error"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
})->throws(ApiException::class);
it('formats error message with detail field', function () {
$errorResponse = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{"detail": "Validation failed"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
try {
$this->apiClient->sendRequest(
method: Method::POST,
endpoint: '/api/users'
);
expect(false)->toBeTrue(); // Should not reach here
} catch (ApiException $e) {
expect($e->getMessage())->toContain('Validation failed');
}
});
it('formats error message with validation_messages', function () {
$errorResponse = new ClientResponse(
status: Status::UNPROCESSABLE_ENTITY,
headers: new Headers([]),
body: '{"detail": "Validation failed", "validation_messages": {"email": "Invalid format"}}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
try {
$this->apiClient->sendRequest(
method: Method::POST,
endpoint: '/api/users'
);
expect(false)->toBeTrue(); // Should not reach here
} catch (ApiException $e) {
expect($e->getMessage())->toContain('Validation failed');
expect($e->getMessage())->toContain('Validierungsfehler');
expect($e->getMessage())->toContain('email');
}
});
it('formats error message with error field', function () {
$errorResponse = new ClientResponse(
status: Status::INTERNAL_SERVER_ERROR,
headers: new Headers([]),
body: '{"error": "Database connection failed"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
try {
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
expect(false)->toBeTrue(); // Should not reach here
} catch (ApiException $e) {
expect($e->getMessage())->toContain('Database connection failed');
}
});
it('formats generic error message when no error fields present', function () {
$errorResponse = new ClientResponse(
status: Status::BAD_REQUEST,
headers: new Headers([]),
body: '{"some_field": "some_value"}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($errorResponse);
try {
$this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
expect(false)->toBeTrue(); // Should not reach here
} catch (ApiException $e) {
expect($e->getMessage())->toBe('API-Fehler');
}
});
it('does not throw exception for 2xx responses', function () {
$successResponse = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{"success": true}'
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($successResponse);
$response = $this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
expect($response->status)->toBe(Status::OK);
});
it('does not throw exception for 3xx responses', function () {
$redirectResponse = new ClientResponse(
status: Status::MOVED_PERMANENTLY,
headers: new Headers(['Location' => 'https://example.com/new']),
body: ''
);
$this->httpClient
->shouldReceive('send')
->once()
->andReturn($redirectResponse);
$response = $this->apiClient->sendRequest(
method: Method::GET,
endpoint: '/api/test'
);
expect($response->status)->toBe(Status::MOVED_PERMANENTLY);
});
});
describe('decodeJson', function () {
it('decodes valid JSON response', function () {
$response = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{"name": "John", "age": 30}'
);
$data = $this->apiClient->testDecodeJson($response);
expect($data)->toBeArray();
expect($data['name'])->toBe('John');
expect($data['age'])->toBe(30);
});
it('returns empty array for invalid JSON', function () {
$response = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: 'Invalid JSON {'
);
$data = $this->apiClient->testDecodeJson($response);
expect($data)->toBe([]);
});
it('returns empty array for empty body', function () {
$response = new ClientResponse(
status: Status::NO_CONTENT,
headers: new Headers([]),
body: ''
);
$data = $this->apiClient->testDecodeJson($response);
expect($data)->toBe([]);
});
it('decodes nested JSON structures', function () {
$response = new ClientResponse(
status: Status::OK,
headers: new Headers([]),
body: '{"user": {"name": "John", "email": "john@example.com"}, "meta": {"total": 1}}'
);
$data = $this->apiClient->testDecodeJson($response);
expect($data)->toBeArray();
expect($data['user'])->toBeArray();
expect($data['user']['name'])->toBe('John');
expect($data['meta']['total'])->toBe(1);
});
});
});

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use App\Framework\Attributes\ApiVersionAttribute;
use App\Framework\Http\Versioning\ApiVersion;
describe('ApiVersionAttribute', function () {
it('constructs with string version', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->version)->toBeInstanceOf(ApiVersion::class);
expect($attribute->version->toString())->toBe('v1.0.0');
expect($attribute->introducedIn)->toBeNull();
expect($attribute->deprecatedIn)->toBeNull();
expect($attribute->removedIn)->toBeNull();
});
it('constructs with ApiVersion object', function () {
$version = ApiVersion::fromString('2.0.0');
$attribute = new ApiVersionAttribute($version);
expect($attribute->version)->toBe($version);
expect($attribute->version->toString())->toBe('v2.0.0');
});
it('constructs with all parameters', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
introducedIn: '2.0.0',
deprecatedIn: '3.0.0',
removedIn: '4.0.0'
);
expect($attribute->version->toString())->toBe('v2.0.0');
expect($attribute->introducedIn)->toBe('2.0.0');
expect($attribute->deprecatedIn)->toBe('3.0.0');
expect($attribute->removedIn)->toBe('4.0.0');
});
it('returns false for isDeprecated when not deprecated', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->isDeprecated())->toBeFalse();
});
it('returns true for isDeprecated when deprecated', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
deprecatedIn: '3.0.0'
);
expect($attribute->isDeprecated())->toBeTrue();
});
it('returns false for isRemoved when not removed', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->isRemoved())->toBeFalse();
});
it('returns true for isRemoved when removed', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
removedIn: '4.0.0'
);
expect($attribute->isRemoved())->toBeTrue();
});
it('returns null for getDeprecatedVersion when not deprecated', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->getDeprecatedVersion())->toBeNull();
});
it('returns ApiVersion for getDeprecatedVersion when deprecated', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
deprecatedIn: '3.0.0'
);
$deprecatedVersion = $attribute->getDeprecatedVersion();
expect($deprecatedVersion)->toBeInstanceOf(ApiVersion::class);
expect($deprecatedVersion->toString())->toBe('v3.0.0');
});
it('returns null for getRemovedVersion when not removed', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->getRemovedVersion())->toBeNull();
});
it('returns ApiVersion for getRemovedVersion when removed', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
removedIn: '4.0.0'
);
$removedVersion = $attribute->getRemovedVersion();
expect($removedVersion)->toBeInstanceOf(ApiVersion::class);
expect($removedVersion->toString())->toBe('v4.0.0');
});
it('returns null for getIntroducedVersion when not specified', function () {
$attribute = new ApiVersionAttribute('1.0.0');
expect($attribute->getIntroducedVersion())->toBeNull();
});
it('returns ApiVersion for getIntroducedVersion when specified', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
introducedIn: '2.0.0'
);
$introducedVersion = $attribute->getIntroducedVersion();
expect($introducedVersion)->toBeInstanceOf(ApiVersion::class);
expect($introducedVersion->toString())->toBe('v2.0.0');
});
it('handles version lifecycle', function () {
$attribute = new ApiVersionAttribute(
version: '2.0.0',
introducedIn: '2.0.0',
deprecatedIn: '3.0.0',
removedIn: '4.0.0'
);
expect($attribute->getIntroducedVersion()->toString())->toBe('v2.0.0');
expect($attribute->getDeprecatedVersion()->toString())->toBe('v3.0.0');
expect($attribute->getRemovedVersion()->toString())->toBe('v4.0.0');
expect($attribute->isDeprecated())->toBeTrue();
expect($attribute->isRemoved())->toBeTrue();
});
});

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Http\Method;
use App\Framework\Router\ValueObjects\RoutePath;
describe('Route', function () {
it('constructs with default values', function () {
$route = new Route(path: '/api/users');
expect($route->path)->toBe('/api/users');
expect($route->method)->toBe(Method::GET);
expect($route->name)->toBeNull();
expect($route->subdomain)->toBe([]);
});
it('constructs with all parameters', function () {
$route = new Route(
path: '/api/users/{id}',
method: Method::POST,
name: 'users.create',
subdomain: 'api'
);
expect($route->path)->toBe('/api/users/{id}');
expect($route->method)->toBe(Method::POST);
expect($route->name)->toBe('users.create');
expect($route->subdomain)->toBe('api');
});
it('accepts RoutePath value object as path', function () {
$routePath = RoutePath::fromString('/api/test');
$route = new Route(path: $routePath);
expect($route->path)->toBe($routePath);
expect($route->getPathAsString())->toBe('/api/test');
});
it('accepts string as path', function () {
$route = new Route(path: '/api/test');
expect($route->path)->toBe('/api/test');
expect($route->getPathAsString())->toBe('/api/test');
});
it('accepts array of subdomains', function () {
$route = new Route(
path: '/api/users',
subdomain: ['api', 'admin']
);
expect($route->subdomain)->toBe(['api', 'admin']);
});
it('returns path as string from string path', function () {
$route = new Route(path: '/api/users');
expect($route->getPathAsString())->toBe('/api/users');
});
it('returns path as string from RoutePath object', function () {
$routePath = RoutePath::fromString('/api/test');
$route = new Route(path: $routePath);
expect($route->getPathAsString())->toBe('/api/test');
});
it('returns RoutePath object from string path', function () {
$route = new Route(path: '/api/users');
$routePath = $route->getRoutePath();
expect($routePath)->toBeInstanceOf(RoutePath::class);
expect($routePath->toString())->toBe('/api/users');
});
it('returns RoutePath object when already RoutePath', function () {
$originalRoutePath = RoutePath::fromString('/api/test');
$route = new Route(path: $originalRoutePath);
$routePath = $route->getRoutePath();
expect($routePath)->toBe($originalRoutePath);
});
it('supports different HTTP methods', function () {
$getMethods = [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH];
foreach ($getMethods as $method) {
$route = new Route(path: '/api/test', method: $method);
expect($route->method)->toBe($method);
}
});
it('handles route names', function () {
$route = new Route(
path: '/api/users',
name: 'users.index'
);
expect($route->name)->toBe('users.index');
});
it('handles null route name', function () {
$route = new Route(path: '/api/users');
expect($route->name)->toBeNull();
});
});

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Framework\Attributes\Singleton;
describe('Singleton', function () {
it('can be instantiated', function () {
$singleton = new Singleton();
expect($singleton)->toBeInstanceOf(Singleton::class);
});
it('is an attribute class', function () {
$reflection = new ReflectionClass(Singleton::class);
$attributes = $reflection->getAttributes(\Attribute::class);
expect($attributes)->toHaveCount(1);
});
it('targets class only', function () {
$reflection = new ReflectionClass(Singleton::class);
$attribute = $reflection->getAttributes(\Attribute::class)[0];
$attributeInstance = $attribute->newInstance();
expect($attributeInstance->flags)->toBe(\Attribute::TARGET_CLASS);
});
});

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Framework\Attributes\StaticPage;
describe('StaticPage', function () {
it('constructs with default values', function () {
$staticPage = new StaticPage();
expect($staticPage->outputPath)->toBeNull();
expect($staticPage->prerender)->toBeTrue();
});
it('constructs with custom output path', function () {
$staticPage = new StaticPage(outputPath: 'custom/path/index.html');
expect($staticPage->outputPath)->toBe('custom/path/index.html');
expect($staticPage->prerender)->toBeTrue();
});
it('constructs with prerender disabled', function () {
$staticPage = new StaticPage(prerender: false);
expect($staticPage->outputPath)->toBeNull();
expect($staticPage->prerender)->toBeFalse();
});
it('constructs with all parameters', function () {
$staticPage = new StaticPage(
outputPath: 'static/pages/about.html',
prerender: false
);
expect($staticPage->outputPath)->toBe('static/pages/about.html');
expect($staticPage->prerender)->toBeFalse();
});
});

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Framework\Auth\PasswordStrength;
describe('PasswordStrength', function () {
it('has all strength levels', function () {
expect(PasswordStrength::VERY_STRONG)->toBeInstanceOf(PasswordStrength::class);
expect(PasswordStrength::STRONG)->toBeInstanceOf(PasswordStrength::class);
expect(PasswordStrength::MODERATE)->toBeInstanceOf(PasswordStrength::class);
expect(PasswordStrength::WEAK)->toBeInstanceOf(PasswordStrength::class);
expect(PasswordStrength::UNKNOWN)->toBeInstanceOf(PasswordStrength::class);
});
it('returns correct labels', function () {
expect(PasswordStrength::VERY_STRONG->getLabel())->toBe('Very Strong');
expect(PasswordStrength::STRONG->getLabel())->toBe('Strong');
expect(PasswordStrength::MODERATE->getLabel())->toBe('Moderate');
expect(PasswordStrength::WEAK->getLabel())->toBe('Weak');
expect(PasswordStrength::UNKNOWN->getLabel())->toBe('Unknown');
});
it('returns correct scores', function () {
expect(PasswordStrength::VERY_STRONG->getScore())->toBe(100);
expect(PasswordStrength::STRONG->getScore())->toBe(80);
expect(PasswordStrength::MODERATE->getScore())->toBe(60);
expect(PasswordStrength::WEAK->getScore())->toBe(30);
expect(PasswordStrength::UNKNOWN->getScore())->toBe(0);
});
it('determines rehash recommendations correctly', function () {
expect(PasswordStrength::VERY_STRONG->shouldRehash())->toBeFalse();
expect(PasswordStrength::STRONG->shouldRehash())->toBeFalse();
expect(PasswordStrength::MODERATE->shouldRehash())->toBeTrue();
expect(PasswordStrength::WEAK->shouldRehash())->toBeTrue();
expect(PasswordStrength::UNKNOWN->shouldRehash())->toBeTrue();
});
it('returns correct colors', function () {
expect(PasswordStrength::VERY_STRONG->getColor())->toBe('#00C853');
expect(PasswordStrength::STRONG->getColor())->toBe('#43A047');
expect(PasswordStrength::MODERATE->getColor())->toBe('#FFA726');
expect(PasswordStrength::WEAK->getColor())->toBe('#EF5350');
expect(PasswordStrength::UNKNOWN->getColor())->toBe('#9E9E9E');
});
it('has string values', function () {
expect(PasswordStrength::VERY_STRONG->value)->toBe('very_strong');
expect(PasswordStrength::STRONG->value)->toBe('strong');
expect(PasswordStrength::MODERATE->value)->toBe('moderate');
expect(PasswordStrength::WEAK->value)->toBe('weak');
expect(PasswordStrength::UNKNOWN->value)->toBe('unknown');
});
});

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
use App\Framework\Auth\PasswordStrength;
use App\Framework\Auth\PasswordValidationResult;
describe('PasswordValidationResult', function () {
it('constructs with all parameters', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: ['Consider adding special characters'],
strengthScore: 75,
strength: PasswordStrength::STRONG
);
expect($result->isValid)->toBeTrue();
expect($result->errors)->toBe([]);
expect($result->warnings)->toBe(['Consider adding special characters']);
expect($result->strengthScore)->toBe(75);
expect($result->strength)->toBe(PasswordStrength::STRONG);
});
it('detects errors', function () {
$result = new PasswordValidationResult(
isValid: false,
errors: ['Too short'],
warnings: [],
strengthScore: 20,
strength: PasswordStrength::WEAK
);
expect($result->hasErrors())->toBeTrue();
});
it('detects no errors', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 90,
strength: PasswordStrength::VERY_STRONG
);
expect($result->hasErrors())->toBeFalse();
});
it('detects warnings', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: ['Add numbers'],
strengthScore: 65,
strength: PasswordStrength::MODERATE
);
expect($result->hasWarnings())->toBeTrue();
});
it('detects no warnings', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 95,
strength: PasswordStrength::VERY_STRONG
);
expect($result->hasWarnings())->toBeFalse();
});
it('combines errors and warnings', function () {
$result = new PasswordValidationResult(
isValid: false,
errors: ['Too short', 'No uppercase'],
warnings: ['Add symbols'],
strengthScore: 30,
strength: PasswordStrength::WEAK
);
$allIssues = $result->getAllIssues();
expect($allIssues)->toHaveCount(3);
expect($allIssues)->toContain('Too short');
expect($allIssues)->toContain('No uppercase');
expect($allIssues)->toContain('Add symbols');
});
it('checks minimum requirements', function () {
$valid = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 60,
strength: PasswordStrength::MODERATE
);
expect($valid->meetsMinimumRequirements())->toBeTrue();
$invalid = new PasswordValidationResult(
isValid: false,
errors: ['Error'],
warnings: [],
strengthScore: 40,
strength: PasswordStrength::WEAK
);
expect($invalid->meetsMinimumRequirements())->toBeFalse();
$lowScore = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 45,
strength: PasswordStrength::WEAK
);
expect($lowScore->meetsMinimumRequirements())->toBeFalse();
});
it('checks if recommended', function () {
$recommended = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 85,
strength: PasswordStrength::STRONG
);
expect($recommended->isRecommended())->toBeTrue();
$withWarnings = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: ['Could be stronger'],
strengthScore: 75,
strength: PasswordStrength::STRONG
);
expect($withWarnings->isRecommended())->toBeFalse();
$lowScore = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 65,
strength: PasswordStrength::MODERATE
);
expect($lowScore->isRecommended())->toBeFalse();
});
it('generates summary for invalid password', function () {
$result = new PasswordValidationResult(
isValid: false,
errors: ['Too short', 'No numbers'],
warnings: [],
strengthScore: 20,
strength: PasswordStrength::WEAK
);
$summary = $result->getSummary();
expect($summary)->toContain('does not meet requirements');
expect($summary)->toContain('Too short');
expect($summary)->toContain('No numbers');
});
it('generates summary with warnings', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: ['Add symbols'],
strengthScore: 65,
strength: PasswordStrength::MODERATE
);
$summary = $result->getSummary();
expect($summary)->toContain('Moderate');
expect($summary)->toContain('suggestions');
expect($summary)->toContain('Add symbols');
});
it('generates summary for valid password', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: [],
strengthScore: 90,
strength: PasswordStrength::VERY_STRONG
);
$summary = $result->getSummary();
expect($summary)->toContain('Very Strong');
expect($summary)->toContain('90/100');
});
it('converts to array', function () {
$result = new PasswordValidationResult(
isValid: true,
errors: [],
warnings: ['Warning message'],
strengthScore: 75,
strength: PasswordStrength::STRONG
);
$array = $result->toArray();
expect($array)->toBeArray();
expect($array['is_valid'])->toBeTrue();
expect($array['errors'])->toBe([]);
expect($array['warnings'])->toBe(['Warning message']);
expect($array['strength_score'])->toBe(75);
expect($array['strength'])->toBe('strong');
expect($array['strength_label'])->toBe('Strong');
expect($array['meets_minimum'])->toBeTrue();
expect($array['is_recommended'])->toBeFalse(); // Has warnings
});
it('creates valid result', function () {
$result = PasswordValidationResult::valid(95);
expect($result->isValid)->toBeTrue();
expect($result->errors)->toBe([]);
expect($result->warnings)->toBe([]);
expect($result->strengthScore)->toBe(95);
expect($result->strength)->toBe(PasswordStrength::VERY_STRONG);
});
it('creates valid result with default score', function () {
$result = PasswordValidationResult::valid();
expect($result->strengthScore)->toBe(100);
expect($result->strength)->toBe(PasswordStrength::VERY_STRONG);
});
it('creates invalid result', function () {
$errors = ['Too short', 'No uppercase'];
$result = PasswordValidationResult::invalid($errors, 25);
expect($result->isValid)->toBeFalse();
expect($result->errors)->toBe($errors);
expect($result->warnings)->toBe([]);
expect($result->strengthScore)->toBe(25);
expect($result->strength)->toBe(PasswordStrength::WEAK);
});
it('creates invalid result with default score', function () {
$result = PasswordValidationResult::invalid(['Error']);
expect($result->strengthScore)->toBe(0);
});
});

View File

@@ -0,0 +1,645 @@
<?php
declare(strict_types=1);
use App\Framework\Config\DockerSecretsResolver;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('DockerSecretsResolver', function () {
beforeEach(function () {
$this->resolver = new DockerSecretsResolver();
// Create temporary test directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0777, true);
}
});
afterEach(function () {
// Clean up test files
if (isset($this->testFile) && file_exists($this->testFile)) {
unlink($this->testFile);
}
});
describe('resolve()', function () {
it('resolves secret from file when {KEY}_FILE exists', function () {
$this->testFile = $this->testDir . '/db_password_secret';
file_put_contents($this->testFile, 'super_secret_password');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('super_secret_password');
});
it('trims whitespace from file content', function () {
$this->testFile = $this->testDir . '/api_key_secret';
file_put_contents($this->testFile, " secret_api_key_123 \n ");
$variables = [
'API_KEY_FILE' => $this->testFile
];
$result = $this->resolver->resolve('API_KEY', $variables);
expect($result)->toBe('secret_api_key_123');
});
it('trims newlines and whitespace', function () {
$this->testFile = $this->testDir . '/token_secret';
file_put_contents($this->testFile, "\nmy_token\n");
$variables = [
'TOKEN_FILE' => $this->testFile
];
$result = $this->resolver->resolve('TOKEN', $variables);
expect($result)->toBe('my_token');
});
it('returns null when {KEY}_FILE variable does not exist', function () {
$variables = [
'DB_PASSWORD' => 'plain_password',
'SOME_OTHER_VAR' => 'value'
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file path is not a string', function () {
$variables = [
'DB_PASSWORD_FILE' => 12345 // Not a string
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file path is an array', function () {
$variables = [
'DB_PASSWORD_FILE' => ['path', 'to', 'file']
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file does not exist', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->testDir . '/non_existent_file'
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file is not readable', function () {
$this->testFile = $this->testDir . '/unreadable_secret';
file_put_contents($this->testFile, 'secret');
chmod($this->testFile, 0000); // Make unreadable
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('returns null when FilePath creation fails', function () {
$variables = [
'DB_PASSWORD_FILE' => '' // Invalid path
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('handles multi-line secret files', function () {
$this->testFile = $this->testDir . '/multi_line_secret';
file_put_contents($this->testFile, "line1\nline2\nline3");
$variables = [
'MULTI_LINE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('MULTI_LINE', $variables);
expect($result)->toBe("line1\nline2\nline3");
});
it('handles empty file', function () {
$this->testFile = $this->testDir . '/empty_secret';
file_put_contents($this->testFile, '');
$variables = [
'EMPTY_FILE' => $this->testFile
];
$result = $this->resolver->resolve('EMPTY', $variables);
expect($result)->toBe('');
});
it('handles file with only whitespace', function () {
$this->testFile = $this->testDir . '/whitespace_secret';
file_put_contents($this->testFile, " \n\n ");
$variables = [
'WHITESPACE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('WHITESPACE', $variables);
expect($result)->toBe('');
});
it('resolves secrets with special characters', function () {
$this->testFile = $this->testDir . '/special_chars_secret';
file_put_contents($this->testFile, 'p@$$w0rd!#%&*()');
$variables = [
'SPECIAL_CHARS_FILE' => $this->testFile
];
$result = $this->resolver->resolve('SPECIAL_CHARS', $variables);
expect($result)->toBe('p@$$w0rd!#%&*()');
});
it('resolves secrets with unicode characters', function () {
$this->testFile = $this->testDir . '/unicode_secret';
file_put_contents($this->testFile, 'pässwörd_日本語');
$variables = [
'UNICODE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('UNICODE', $variables);
expect($result)->toBe('pässwörd_日本語');
});
it('resolves absolute file paths', function () {
$this->testFile = $this->testDir . '/absolute_path_secret';
file_put_contents($this->testFile, 'absolute_secret');
$variables = [
'ABS_PATH_FILE' => $this->testFile
];
$result = $this->resolver->resolve('ABS_PATH', $variables);
expect($result)->toBe('absolute_secret');
});
});
describe('hasSecret()', function () {
it('returns true when secret exists and is readable', function () {
$this->testFile = $this->testDir . '/db_password_secret';
file_put_contents($this->testFile, 'secret');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeTrue();
});
it('returns false when {KEY}_FILE variable does not exist', function () {
$variables = [
'DB_PASSWORD' => 'plain_password'
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
it('returns false when file does not exist', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->testDir . '/non_existent_file'
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
it('returns false when file is not readable', function () {
$this->testFile = $this->testDir . '/unreadable_secret';
file_put_contents($this->testFile, 'secret');
chmod($this->testFile, 0000);
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('returns true for empty file', function () {
$this->testFile = $this->testDir . '/empty_secret';
file_put_contents($this->testFile, '');
$variables = [
'EMPTY_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('EMPTY', $variables);
expect($result)->toBeTrue();
});
it('returns false when file path is not a string', function () {
$variables = [
'DB_PASSWORD_FILE' => 12345
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
});
describe('resolveMultiple()', function () {
beforeEach(function () {
// Create multiple secret files
$this->dbPasswordFile = $this->testDir . '/db_password';
$this->apiKeyFile = $this->testDir . '/api_key';
$this->smtpPasswordFile = $this->testDir . '/smtp_password';
file_put_contents($this->dbPasswordFile, 'db_secret_123');
file_put_contents($this->apiKeyFile, 'api_key_456');
file_put_contents($this->smtpPasswordFile, 'smtp_secret_789');
});
afterEach(function () {
// Clean up multiple test files
foreach ([$this->dbPasswordFile, $this->apiKeyFile, $this->smtpPasswordFile] as $file) {
if (file_exists($file)) {
unlink($file);
}
}
});
it('resolves multiple secrets successfully', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('resolves only available secrets', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
// SMTP_PASSWORD_FILE missing
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
// SMTP_PASSWORD not in result
]);
});
it('returns empty array when no secrets are available', function () {
$variables = [
'DB_PASSWORD' => 'plain_password',
'API_KEY' => 'plain_key'
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([]);
});
it('returns empty array for empty keys list', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile
];
$result = $this->resolver->resolveMultiple([], $variables);
expect($result)->toBe([]);
});
it('skips non-existent files', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->testDir . '/non_existent',
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('skips unreadable files', function () {
chmod($this->apiKeyFile, 0000);
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
// Restore permissions for cleanup
chmod($this->apiKeyFile, 0644);
});
it('trims whitespace from all resolved secrets', function () {
file_put_contents($this->dbPasswordFile, " db_secret \n");
file_put_contents($this->apiKeyFile, "\n api_key ");
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
];
$keys = ['DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret',
'API_KEY' => 'api_key'
]);
});
it('handles mixed success and failure scenarios', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => 12345, // Invalid (not string)
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile,
'MISSING_FILE' => $this->testDir . '/non_existent'
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD', 'MISSING'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('preserves order of successfully resolved secrets', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['SMTP_PASSWORD', 'DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'SMTP_PASSWORD' => 'smtp_secret_789',
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
]);
});
it('handles duplicate keys in input', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
// Last duplicate wins
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
]);
});
});
describe('edge cases', function () {
it('handles very long file paths', function () {
$longPath = $this->testDir . '/' . str_repeat('a', 200) . '_secret';
$this->testFile = $longPath;
file_put_contents($this->testFile, 'long_path_secret');
$variables = [
'LONG_PATH_FILE' => $this->testFile
];
$result = $this->resolver->resolve('LONG_PATH', $variables);
expect($result)->toBe('long_path_secret');
});
it('handles very large file content', function () {
$this->testFile = $this->testDir . '/large_secret';
$largeContent = str_repeat('secret', 10000); // 60KB content
file_put_contents($this->testFile, $largeContent);
$variables = [
'LARGE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('LARGE', $variables);
expect($result)->toBe($largeContent);
});
it('handles file with null bytes', function () {
$this->testFile = $this->testDir . '/null_bytes_secret';
file_put_contents($this->testFile, "secret\0with\0nulls");
$variables = [
'NULL_BYTES_FILE' => $this->testFile
];
$result = $this->resolver->resolve('NULL_BYTES', $variables);
expect($result)->toBe("secret\0with\0nulls");
});
it('handles concurrent access to same file', function () {
$this->testFile = $this->testDir . '/concurrent_secret';
file_put_contents($this->testFile, 'concurrent_value');
$variables = [
'CONCURRENT_FILE' => $this->testFile
];
// Simulate concurrent access
$result1 = $this->resolver->resolve('CONCURRENT', $variables);
$result2 = $this->resolver->resolve('CONCURRENT', $variables);
expect($result1)->toBe('concurrent_value');
expect($result2)->toBe('concurrent_value');
});
it('handles symlinks to secret files', function () {
$this->testFile = $this->testDir . '/original_secret';
$symlinkPath = $this->testDir . '/symlink_secret';
file_put_contents($this->testFile, 'symlink_value');
symlink($this->testFile, $symlinkPath);
$variables = [
'SYMLINK_FILE' => $symlinkPath
];
$result = $this->resolver->resolve('SYMLINK', $variables);
expect($result)->toBe('symlink_value');
// Cleanup symlink
unlink($symlinkPath);
});
it('returns null for directories', function () {
$dirPath = $this->testDir . '/secret_dir';
mkdir($dirPath, 0777, true);
$variables = [
'DIR_FILE' => $dirPath
];
$result = $this->resolver->resolve('DIR', $variables);
expect($result)->toBeNull();
// Cleanup directory
rmdir($dirPath);
});
});
describe('Docker Secrets real-world scenarios', function () {
it('resolves typical Docker Swarm secret', function () {
$this->testFile = $this->testDir . '/docker_secret';
// Docker secrets typically have trailing newline
file_put_contents($this->testFile, "production_password\n");
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('production_password');
});
it('resolves secrets from /run/secrets path pattern', function () {
// Simulate Docker Swarm /run/secrets pattern
$secretsDir = $this->testDir . '/run/secrets';
mkdir($secretsDir, 0777, true);
$this->testFile = $secretsDir . '/db_password';
file_put_contents($this->testFile, 'swarm_secret');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('swarm_secret');
// Cleanup
unlink($this->testFile);
rmdir($secretsDir);
});
it('handles multiple Docker secrets in production environment', function () {
$secretsDir = $this->testDir . '/run/secrets';
mkdir($secretsDir, 0777, true);
$dbPassword = $secretsDir . '/db_password';
$apiKey = $secretsDir . '/api_key';
$jwtSecret = $secretsDir . '/jwt_secret';
file_put_contents($dbPassword, "prod_db_pass\n");
file_put_contents($apiKey, "prod_api_key\n");
file_put_contents($jwtSecret, "prod_jwt_secret\n");
$variables = [
'DB_PASSWORD_FILE' => $dbPassword,
'API_KEY_FILE' => $apiKey,
'JWT_SECRET_FILE' => $jwtSecret
];
$keys = ['DB_PASSWORD', 'API_KEY', 'JWT_SECRET'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'prod_db_pass',
'API_KEY' => 'prod_api_key',
'JWT_SECRET' => 'prod_jwt_secret'
]);
// Cleanup
unlink($dbPassword);
unlink($apiKey);
unlink($jwtSecret);
rmdir($secretsDir);
});
});
});

View File

@@ -0,0 +1,770 @@
<?php
declare(strict_types=1);
use App\Framework\Config\EncryptedEnvLoader;
use App\Framework\Config\EnvFileParser;
use App\Framework\Config\Environment;
use App\Framework\Encryption\EncryptionFactory;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('EncryptedEnvLoader', function () {
beforeEach(function () {
$this->encryptionFactory = new EncryptionFactory();
$this->parser = new EnvFileParser();
$this->loader = new EncryptedEnvLoader($this->encryptionFactory, $this->parser);
// Create temporary test directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0777, true);
}
// Backup and clear real environment variables
$this->originalEnv = $_ENV;
$this->originalServer = $_SERVER;
$_ENV = [];
$_SERVER = [];
});
afterEach(function () {
// Restore original environment
$_ENV = $this->originalEnv;
$_SERVER = $this->originalServer;
// Clean up test files
if (isset($this->envFile) && file_exists($this->envFile)) {
unlink($this->envFile);
}
if (isset($this->secretsFile) && file_exists($this->secretsFile)) {
unlink($this->secretsFile);
}
if (isset($this->envProductionFile) && file_exists($this->envProductionFile)) {
unlink($this->envProductionFile);
}
if (isset($this->envDevelopmentFile) && file_exists($this->envDevelopmentFile)) {
unlink($this->envDevelopmentFile);
}
});
describe('load()', function () {
it('loads environment from .env file', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
APP_ENV=development
DB_HOST=localhost
DB_PORT=3306
ENV);
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('APP_ENV'))->toBe('development');
expect($env->get('DB_HOST'))->toBe('localhost');
expect($env->getInt('DB_PORT'))->toBe(3306);
});
it('loads environment with FilePath object', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
APP_ENV=development
ENV);
$filePath = FilePath::create($this->testDir);
$env = $this->loader->load($filePath);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('APP_ENV'))->toBe('development');
});
it('returns empty environment when no .env file exists', function () {
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBeNull();
});
it('performs two-pass loading when ENCRYPTION_KEY present', function () {
// First pass: Load .env with ENCRYPTION_KEY
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
ENCRYPTION_KEY=test_encryption_key_32_chars_long
ENV);
// Second pass: Load .env.secrets
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret_api_key
ENV);
$env = $this->loader->load($this->testDir);
// Should have loaded from both files
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('ENCRYPTION_KEY'))->toBe('test_encryption_key_32_chars_long');
expect($env->get('SECRET_API_KEY'))->toBe('my_secret_api_key');
});
it('continues without secrets when encryption fails', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
ENCRYPTION_KEY=invalid_key
ENV);
// Corrupted secrets file that will fail decryption
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, 'CORRUPTED_DATA');
// Should not throw - graceful degradation
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('ENCRYPTION_KEY'))->toBe('invalid_key');
});
});
describe('loadEnvironment() - Production Priority', function () {
it('prioritizes Docker ENV vars over .env file in production', function () {
// Simulate Docker environment variables
$_ENV['APP_ENV'] = 'production';
$_ENV['DB_HOST'] = 'docker_mysql';
$_ENV['DB_PORT'] = '3306';
// .env file with different values
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_ENV=production
DB_HOST=localhost
DB_PORT=3307
DB_NAME=mydb
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Docker ENV vars should take precedence
expect($env->get('DB_HOST'))->toBe('docker_mysql');
expect($env->getInt('DB_PORT'))->toBe(3306);
// .env values only used if not in Docker ENV
expect($env->get('DB_NAME'))->toBe('mydb');
});
it('loads environment-specific file in production', function () {
$_ENV['APP_ENV'] = 'production';
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
APP_ENV=production
ENV);
$this->envProductionFile = $this->testDir . '/.env.production';
file_put_contents($this->envProductionFile, <<<ENV
PRODUCTION_SETTING=enabled
DB_HOST=prod_host
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('PRODUCTION_SETTING'))->toBe('enabled');
expect($env->get('DB_HOST'))->toBe('prod_host');
});
it('does not override Docker ENV with production file', function () {
$_ENV['APP_ENV'] = 'production';
$_ENV['DB_HOST'] = 'docker_production_host';
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, 'APP_ENV=production');
$this->envProductionFile = $this->testDir . '/.env.production';
file_put_contents($this->envProductionFile, <<<ENV
DB_HOST=file_production_host
DB_PORT=3306
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Docker ENV should win
expect($env->get('DB_HOST'))->toBe('docker_production_host');
// New variables from production file should be added
expect($env->getInt('DB_PORT'))->toBe(3306);
});
});
describe('loadEnvironment() - Development Priority', function () {
it('allows .env file to override system environment in development', function () {
// Simulate system environment
$_ENV['APP_ENV'] = 'development';
$_ENV['DB_HOST'] = 'system_host';
// .env file with different values
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_ENV=development
DB_HOST=localhost
DB_PORT=3307
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// .env file should override in development
expect($env->get('DB_HOST'))->toBe('localhost');
expect($env->getInt('DB_PORT'))->toBe(3307);
});
it('loads environment-specific file in development', function () {
$_ENV['APP_ENV'] = 'development';
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
APP_ENV=development
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DEBUG=true
DB_HOST=localhost
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->getBool('DEBUG'))->toBeTrue();
expect($env->get('DB_HOST'))->toBe('localhost');
});
it('allows development file to override .env in development', function () {
$_ENV['APP_ENV'] = 'development';
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_ENV=development
DB_HOST=localhost
DEBUG=false
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DB_HOST=dev_override
DEBUG=true
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Development file should override .env
expect($env->get('DB_HOST'))->toBe('dev_override');
expect($env->getBool('DEBUG'))->toBeTrue();
});
});
describe('loadSystemEnvironment() - getenv() Priority Fix', function () {
it('loads from getenv() first (PHP-FPM compatibility)', function () {
// Simulate PHP-FPM scenario where $_ENV is empty but getenv() works
$_ENV = [];
$_SERVER = [];
// getenv() returns values (simulated by putenv for test)
putenv('TEST_VAR=from_getenv');
$env = $this->loader->loadEnvironment($this->testDir);
// Should have loaded from getenv()
expect($env->get('TEST_VAR'))->toBe('from_getenv');
// Cleanup
putenv('TEST_VAR');
});
it('falls back to $_ENV when getenv() is empty', function () {
$_ENV['FALLBACK_VAR'] = 'from_env_array';
$_SERVER = [];
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('FALLBACK_VAR'))->toBe('from_env_array');
});
it('falls back to $_SERVER when both getenv() and $_ENV are empty', function () {
$_ENV = [];
$_SERVER['SERVER_VAR'] = 'from_server_array';
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('SERVER_VAR'))->toBe('from_server_array');
});
it('filters non-string values from $_SERVER', function () {
$_ENV = [];
$_SERVER['STRING_VAR'] = 'valid_string';
$_SERVER['ARRAY_VAR'] = ['invalid', 'array'];
$_SERVER['INT_VAR'] = 123; // Will be converted to string
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('STRING_VAR'))->toBe('valid_string');
expect($env->get('ARRAY_VAR'))->toBeNull(); // Filtered out
});
it('prefers getenv() over $_ENV and $_SERVER', function () {
// Simulate all three sources with different values
putenv('PRIORITY_TEST=from_getenv');
$_ENV['PRIORITY_TEST'] = 'from_env_array';
$_SERVER['PRIORITY_TEST'] = 'from_server_array';
$env = $this->loader->loadEnvironment($this->testDir);
// getenv() should win
expect($env->get('PRIORITY_TEST'))->toBe('from_getenv');
// Cleanup
putenv('PRIORITY_TEST');
});
});
describe('loadEnvironment() - .env.secrets Support', function () {
it('loads secrets file when encryption key provided', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, 'APP_ENV=development');
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret
SECRET_DB_PASSWORD=db_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->loader->loadEnvironment($this->testDir, $encryptionKey);
expect($env->get('SECRET_API_KEY'))->toBe('my_secret');
expect($env->get('SECRET_DB_PASSWORD'))->toBe('db_secret');
});
it('skips secrets file when encryption key is null', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, 'APP_ENV=development');
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, 'SECRET_API_KEY=my_secret');
$env = $this->loader->loadEnvironment($this->testDir, encryptionKey: null);
expect($env->get('SECRET_API_KEY'))->toBeNull();
});
it('skips secrets file when it does not exist', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, 'APP_ENV=development');
// No secrets file created
$encryptionKey = 'test_encryption_key_32_chars_long';
// Should not throw
$env = $this->loader->loadEnvironment($this->testDir, $encryptionKey);
expect($env->get('APP_ENV'))->toBe('development');
});
it('merges secrets with existing variables', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_ENV=development
DB_HOST=localhost
ENV);
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret
SECRET_DB_PASSWORD=db_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->loader->loadEnvironment($this->testDir, $encryptionKey);
// Base .env variables
expect($env->get('APP_ENV'))->toBe('development');
expect($env->get('DB_HOST'))->toBe('localhost');
// Secrets
expect($env->get('SECRET_API_KEY'))->toBe('my_secret');
expect($env->get('SECRET_DB_PASSWORD'))->toBe('db_secret');
});
});
describe('generateSecretsTemplate()', function () {
it('generates secrets template file', function () {
$filePath = $this->loader->generateSecretsTemplate($this->testDir);
$this->secretsFile = $filePath->toString();
expect(file_exists($this->secretsFile))->toBeTrue();
$content = file_get_contents($this->secretsFile);
// Check header
expect($content)->toContain('# .env.secrets - Encrypted secrets file');
expect($content)->toContain('# Generated on');
// Check instructions
expect($content)->toContain('# Instructions:');
expect($content)->toContain('# 1. Set ENCRYPTION_KEY in your main .env file');
// Check example format
expect($content)->toContain('# Example:');
expect($content)->toContain('# SECRET_API_KEY=ENC[base64encodedencryptedvalue]');
// Check default secret keys
expect($content)->toContain('# SECRET_ENCRYPTION_KEY=');
expect($content)->toContain('# SECRET_DATABASE_PASSWORD=');
expect($content)->toContain('# SECRET_API_KEY=');
expect($content)->toContain('# SECRET_JWT_SECRET=');
});
it('includes custom secret keys in template', function () {
$customKeys = [
'SECRET_STRIPE_KEY',
'SECRET_AWS_ACCESS_KEY',
'SECRET_MAILGUN_KEY'
];
$filePath = $this->loader->generateSecretsTemplate($this->testDir, $customKeys);
$this->secretsFile = $filePath->toString();
$content = file_get_contents($this->secretsFile);
// Check custom keys are included
expect($content)->toContain('# SECRET_STRIPE_KEY=');
expect($content)->toContain('# SECRET_AWS_ACCESS_KEY=');
expect($content)->toContain('# SECRET_MAILGUN_KEY=');
// Default keys should still be present
expect($content)->toContain('# SECRET_API_KEY=');
});
it('removes duplicate keys in template', function () {
$customKeys = [
'SECRET_API_KEY', // Duplicate of default
'SECRET_CUSTOM_KEY'
];
$filePath = $this->loader->generateSecretsTemplate($this->testDir, $customKeys);
$this->secretsFile = $filePath->toString();
$content = file_get_contents($this->secretsFile);
// Count occurrences of SECRET_API_KEY
$count = substr_count($content, 'SECRET_API_KEY');
// Should only appear once (no duplicates)
expect($count)->toBe(1);
});
});
describe('encryptSecretsInFile()', function () {
it('encrypts secrets with SECRET_ prefix', function () {
$this->secretsFile = $this->testDir . '/.env.test';
file_put_contents($this->secretsFile, <<<ENV
# Test secrets file
SECRET_API_KEY=my_plain_secret
SECRET_DB_PASSWORD=plain_password
NORMAL_VAR=not_encrypted
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->loader->encryptSecretsInFile(
$this->secretsFile,
$encryptionKey
);
expect($encryptedCount)->toBe(2);
$content = file_get_contents($this->secretsFile);
// Secrets should be encrypted (not plain text)
expect($content)->not->toContain('my_plain_secret');
expect($content)->not->toContain('plain_password');
// Normal var should remain unchanged
expect($content)->toContain('NORMAL_VAR=not_encrypted');
// Comments should be preserved
expect($content)->toContain('# Test secrets file');
});
it('encrypts only specified keys when provided', function () {
$this->secretsFile = $this->testDir . '/.env.test';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_plain_secret
SECRET_DB_PASSWORD=plain_password
SECRET_OTHER=other_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$keysToEncrypt = ['SECRET_API_KEY', 'SECRET_OTHER'];
$encryptedCount = $this->loader->encryptSecretsInFile(
$this->secretsFile,
$encryptionKey,
$keysToEncrypt
);
expect($encryptedCount)->toBe(2);
$content = file_get_contents($this->secretsFile);
// Specified secrets should be encrypted
expect($content)->not->toContain('my_plain_secret');
expect($content)->not->toContain('other_secret');
// Non-specified secret should remain plain (but still there)
expect($content)->toContain('SECRET_DB_PASSWORD=plain_password');
});
it('skips already encrypted values', function () {
$this->secretsFile = $this->testDir . '/.env.test';
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryption = $this->encryptionFactory->createBest($encryptionKey);
$alreadyEncrypted = $encryption->encrypt('already_encrypted_value');
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY={$alreadyEncrypted}
SECRET_DB_PASSWORD=plain_password
ENV);
$encryptedCount = $this->loader->encryptSecretsInFile(
$this->secretsFile,
$encryptionKey
);
// Only 1 should be encrypted (the plain one)
expect($encryptedCount)->toBe(1);
});
it('returns 0 when file does not exist', function () {
$nonExistentFile = $this->testDir . '/non_existent.env';
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->loader->encryptSecretsInFile(
$nonExistentFile,
$encryptionKey
);
expect($encryptedCount)->toBe(0);
});
it('returns 0 when file is not readable', function () {
$this->secretsFile = $this->testDir . '/.env.unreadable';
file_put_contents($this->secretsFile, 'SECRET_KEY=value');
chmod($this->secretsFile, 0000);
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->loader->encryptSecretsInFile(
$this->secretsFile,
$encryptionKey
);
expect($encryptedCount)->toBe(0);
// Restore permissions for cleanup
chmod($this->secretsFile, 0644);
});
it('removes quotes before encryption', function () {
$this->secretsFile = $this->testDir . '/.env.test';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY="quoted_value"
SECRET_DB_PASSWORD='single_quoted'
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->loader->encryptSecretsInFile(
$this->secretsFile,
$encryptionKey
);
expect($encryptedCount)->toBe(2);
// Values should be encrypted without quotes
$content = file_get_contents($this->secretsFile);
expect($content)->not->toContain('"quoted_value"');
expect($content)->not->toContain("'single_quoted'");
});
});
describe('validateEncryptionSetup()', function () {
it('returns no issues when setup is valid', function () {
$encryptionKey = str_repeat('a', 32); // 32 characters
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, 'SECRET_KEY=value');
$issues = $this->loader->validateEncryptionSetup($this->testDir, $encryptionKey);
expect($issues)->toBe([]);
});
it('reports missing encryption key', function () {
$issues = $this->loader->validateEncryptionSetup($this->testDir, encryptionKey: null);
expect($issues)->toHaveCount(1);
expect($issues[0]['type'])->toBe('missing_key');
expect($issues[0]['severity'])->toBe('high');
expect($issues[0]['message'])->toBe('No encryption key provided');
});
it('reports weak encryption key', function () {
$weakKey = 'short_key'; // Less than 32 characters
$issues = $this->loader->validateEncryptionSetup($this->testDir, $weakKey);
expect($issues)->toHaveCount(1);
expect($issues[0]['type'])->toBe('weak_key');
expect($issues[0]['severity'])->toBe('high');
expect($issues[0]['message'])->toContain('at least 32 characters');
});
it('reports missing secrets file', function () {
$encryptionKey = str_repeat('a', 32);
// No secrets file created
$issues = $this->loader->validateEncryptionSetup($this->testDir, $encryptionKey);
expect($issues)->toHaveCount(1);
expect($issues[0]['type'])->toBe('missing_secrets_file');
expect($issues[0]['severity'])->toBe('medium');
expect($issues[0]['message'])->toBe('.env.secrets file not found');
});
it('reports multiple issues', function () {
$weakKey = 'weak'; // Weak key
// No secrets file
$issues = $this->loader->validateEncryptionSetup($this->testDir, $weakKey);
expect($issues)->toHaveCount(2);
$types = array_column($issues, 'type');
expect($types)->toContain('weak_key');
expect($types)->toContain('missing_secrets_file');
});
});
describe('Edge Cases', function () {
it('handles empty .env file', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, '');
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBeNull();
});
it('handles .env file with only comments', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
# Comment line 1
# Comment line 2
# Comment line 3
ENV);
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBeNull();
});
it('handles .env file with blank lines', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
DB_HOST=localhost
ENV);
$env = $this->loader->load($this->testDir);
expect($env->get('APP_NAME'))->toBe('TestApp');
expect($env->get('DB_HOST'))->toBe('localhost');
});
it('re-checks APP_ENV after .env loading', function () {
// System says production
$_ENV['APP_ENV'] = 'production';
// But .env overrides to development (in development mode, this works)
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_ENV=development
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DEV_SETTING=enabled
ENV);
// Since .env says development, should load .env.development
$env = $this->loader->loadEnvironment($this->testDir);
expect($env->get('APP_ENV'))->toBe('production'); // System ENV wins in production check
// But development file should be loaded based on re-check
});
it('handles multiple environment file layers', function () {
$_ENV['APP_ENV'] = 'development';
// Layer 1: Base .env
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=BaseApp
DB_HOST=localhost
DEBUG=false
ENV);
// Layer 2: Environment-specific
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DB_HOST=dev_host
DEBUG=true
ENV);
// Layer 3: Secrets
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_KEY=my_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->loader->loadEnvironment($this->testDir, $encryptionKey);
// Base layer
expect($env->get('APP_NAME'))->toBe('BaseApp');
// Override by development
expect($env->get('DB_HOST'))->toBe('dev_host');
expect($env->getBool('DEBUG'))->toBeTrue();
// Secrets layer
expect($env->get('SECRET_KEY'))->toBe('my_secret');
});
});
});

View File

@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
use App\Framework\Config\EnvFileParser;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('EnvFileParser', function () {
beforeEach(function () {
$this->parser = new EnvFileParser();
});
describe('parseString()', function () {
it('parses basic key=value pairs', function () {
$content = <<<ENV
KEY1=value1
KEY2=value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles empty lines', function () {
$content = <<<ENV
KEY1=value1
KEY2=value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('skips comment lines', function () {
$content = <<<ENV
# This is a comment
KEY1=value1
# Another comment
KEY2=value2
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('removes double quotes from values', function () {
$content = <<<ENV
KEY1="quoted value"
KEY2="value with spaces"
KEY3="123"
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'quoted value',
'KEY2' => 'value with spaces',
'KEY3' => 123, // Numeric string gets cast to int
]);
});
it('removes single quotes from values', function () {
$content = <<<ENV
KEY1='single quoted'
KEY2='value with spaces'
KEY3='true'
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'single quoted',
'KEY2' => 'value with spaces',
'KEY3' => true, // 'true' gets cast to boolean
]);
});
it('casts "true" to boolean true', function () {
$content = <<<ENV
BOOL1=true
BOOL2=True
BOOL3=TRUE
ENV;
$result = $this->parser->parseString($content);
expect($result['BOOL1'])->toBeTrue();
expect($result['BOOL2'])->toBeTrue();
expect($result['BOOL3'])->toBeTrue();
});
it('casts "false" to boolean false', function () {
$content = <<<ENV
BOOL1=false
BOOL2=False
BOOL3=FALSE
ENV;
$result = $this->parser->parseString($content);
expect($result['BOOL1'])->toBeFalse();
expect($result['BOOL2'])->toBeFalse();
expect($result['BOOL3'])->toBeFalse();
});
it('casts "null" to null', function () {
$content = <<<ENV
NULL1=null
NULL2=Null
NULL3=NULL
ENV;
$result = $this->parser->parseString($content);
expect($result['NULL1'])->toBeNull();
expect($result['NULL2'])->toBeNull();
expect($result['NULL3'])->toBeNull();
});
it('casts numeric strings to integers', function () {
$content = <<<ENV
INT1=0
INT2=123
INT3=456
INT4=-789
ENV;
$result = $this->parser->parseString($content);
expect($result['INT1'])->toBe(0);
expect($result['INT2'])->toBe(123);
expect($result['INT3'])->toBe(456);
expect($result['INT4'])->toBe(-789);
});
it('casts numeric strings with decimals to floats', function () {
$content = <<<ENV
FLOAT1=0.0
FLOAT2=123.45
FLOAT3=-67.89
ENV;
$result = $this->parser->parseString($content);
expect($result['FLOAT1'])->toBe(0.0);
expect($result['FLOAT2'])->toBe(123.45);
expect($result['FLOAT3'])->toBe(-67.89);
});
it('keeps non-numeric strings as strings', function () {
$content = <<<ENV
STR1=hello
STR2=world123
STR3=abc-def
ENV;
$result = $this->parser->parseString($content);
expect($result['STR1'])->toBe('hello');
expect($result['STR2'])->toBe('world123');
expect($result['STR3'])->toBe('abc-def');
});
it('trims whitespace around keys and values', function () {
$content = <<<ENV
KEY1 = value1
KEY2= value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles empty values', function () {
$content = <<<ENV
EMPTY1=
EMPTY2=""
EMPTY3=''
ENV;
$result = $this->parser->parseString($content);
expect($result['EMPTY1'])->toBe('');
expect($result['EMPTY2'])->toBe('');
expect($result['EMPTY3'])->toBe('');
});
it('handles values with equals signs', function () {
$content = <<<ENV
URL=https://example.com?param=value
EQUATION=a=b+c
ENV;
$result = $this->parser->parseString($content);
expect($result['URL'])->toBe('https://example.com?param=value');
expect($result['EQUATION'])->toBe('a=b+c');
});
it('skips lines without equals sign', function () {
$content = <<<ENV
KEY1=value1
INVALID_LINE_WITHOUT_EQUALS
KEY2=value2
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('handles mixed content with all features', function () {
$content = <<<ENV
# Database configuration
DB_HOST=localhost
DB_PORT=3306
DB_NAME="my_database"
DB_USER='root'
DB_PASS=
# Application settings
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
# Feature flags
FEATURE_ENABLED=true
CACHE_TTL=3600
API_TIMEOUT=30.5
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'DB_HOST' => 'localhost',
'DB_PORT' => 3306,
'DB_NAME' => 'my_database',
'DB_USER' => 'root',
'DB_PASS' => '',
'APP_ENV' => 'production',
'APP_DEBUG' => false,
'APP_URL' => 'https://example.com',
'FEATURE_ENABLED' => true,
'CACHE_TTL' => 3600,
'API_TIMEOUT' => 30.5,
]);
});
});
describe('removeQuotes()', function () {
it('removes double quotes', function () {
$result = $this->parser->removeQuotes('"quoted value"');
expect($result)->toBe('quoted value');
});
it('removes single quotes', function () {
$result = $this->parser->removeQuotes("'quoted value'");
expect($result)->toBe('quoted value');
});
it('keeps unquoted values unchanged', function () {
$result = $this->parser->removeQuotes('unquoted');
expect($result)->toBe('unquoted');
});
it('keeps mismatched quotes unchanged', function () {
$result1 = $this->parser->removeQuotes('"mismatched\'');
$result2 = $this->parser->removeQuotes('\'mismatched"');
expect($result1)->toBe('"mismatched\'');
expect($result2)->toBe('\'mismatched"');
});
it('keeps empty quoted strings as empty', function () {
$result1 = $this->parser->removeQuotes('""');
$result2 = $this->parser->removeQuotes("''");
expect($result1)->toBe('');
expect($result2)->toBe('');
});
it('keeps single quote character unchanged', function () {
$result = $this->parser->removeQuotes('"');
expect($result)->toBe('"');
});
});
describe('parse() with FilePath', function () {
beforeEach(function () {
// Create temporary test directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0777, true);
}
});
afterEach(function () {
// Clean up test files
if (isset($this->testFile) && file_exists($this->testFile)) {
unlink($this->testFile);
}
});
it('parses file with FilePath object', function () {
$this->testFile = $this->testDir . '/test.env';
file_put_contents($this->testFile, "KEY1=value1\nKEY2=value2");
$filePath = FilePath::create($this->testFile);
$result = $this->parser->parse($filePath);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('parses file with string path', function () {
$this->testFile = $this->testDir . '/test.env';
file_put_contents($this->testFile, "KEY1=value1\nKEY2=value2");
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('returns empty array for non-existent file', function () {
$result = $this->parser->parse($this->testDir . '/non-existent.env');
expect($result)->toBe([]);
});
it('returns empty array for unreadable file', function () {
$this->testFile = $this->testDir . '/unreadable.env';
file_put_contents($this->testFile, "KEY=value");
chmod($this->testFile, 0000); // Make unreadable
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([]);
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('parses real .env file format', function () {
$this->testFile = $this->testDir . '/.env.test';
$content = <<<ENV
# Environment Configuration
APP_NAME="My Application"
APP_ENV=production
APP_DEBUG=false
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
# Redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
ENV;
file_put_contents($this->testFile, $content);
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([
'APP_NAME' => 'My Application',
'APP_ENV' => 'production',
'APP_DEBUG' => false,
'DB_CONNECTION' => 'mysql',
'DB_HOST' => '127.0.0.1',
'DB_PORT' => 3306,
'DB_DATABASE' => 'homestead',
'DB_USERNAME' => 'homestead',
'DB_PASSWORD' => 'secret',
'REDIS_HOST' => '127.0.0.1',
'REDIS_PASSWORD' => null,
'REDIS_PORT' => 6379,
]);
});
});
describe('edge cases', function () {
it('handles Windows line endings (CRLF)', function () {
$content = "KEY1=value1\r\nKEY2=value2\r\n";
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('handles mixed line endings', function () {
$content = "KEY1=value1\nKEY2=value2\r\nKEY3=value3\r";
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles keys with underscores and numbers', function () {
$content = <<<ENV
VAR_1=value1
VAR_2_TEST=value2
VAR123=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'VAR_1' => 'value1',
'VAR_2_TEST' => 'value2',
'VAR123' => 'value3',
]);
});
it('handles special characters in values', function () {
$content = <<<ENV
SPECIAL1=value!@#$%
SPECIAL2=value^&*()
SPECIAL3=value[]{}
ENV;
$result = $this->parser->parseString($content);
expect($result['SPECIAL1'])->toBe('value!@#$%');
expect($result['SPECIAL2'])->toBe('value^&*()');
expect($result['SPECIAL3'])->toBe('value[]{}');
});
it('handles URL values correctly', function () {
$content = <<<ENV
URL1=http://localhost:8080
URL2=https://example.com/path?param=value&other=123
URL3=mysql://user:pass@localhost:3306/database
ENV;
$result = $this->parser->parseString($content);
expect($result['URL1'])->toBe('http://localhost:8080');
expect($result['URL2'])->toBe('https://example.com/path?param=value&other=123');
expect($result['URL3'])->toBe('mysql://user:pass@localhost:3306/database');
});
it('overrides duplicate keys with last value', function () {
$content = <<<ENV
KEY=value1
KEY=value2
KEY=value3
ENV;
$result = $this->parser->parseString($content);
expect($result['KEY'])->toBe('value3');
});
});
});

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
describe('Environment - Docker Secrets Support', function () {
beforeEach(function () {
// Setup test directory for secret files
$this->secretsDir = sys_get_temp_dir() . '/test-secrets-' . uniqid();
mkdir($this->secretsDir, 0700, true);
});
afterEach(function () {
// Cleanup test directory
if (is_dir($this->secretsDir)) {
array_map('unlink', glob($this->secretsDir . '/*'));
rmdir($this->secretsDir);
}
});
it('returns direct environment variable when available', function () {
$env = new Environment(['DB_PASSWORD' => 'direct_password']);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('direct_password');
});
it('reads from *_FILE when direct variable not available', function () {
// Create secret file
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'file_password');
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('file_password');
});
it('trims whitespace from file contents', function () {
// Docker secrets often have trailing newlines
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, "file_password\n");
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('file_password');
});
it('prioritizes direct variable over *_FILE', function () {
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'file_password');
$env = new Environment([
'DB_PASSWORD' => 'direct_password',
'DB_PASSWORD_FILE' => $secretFile
]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('direct_password')
->and($result)->not->toBe('file_password');
});
it('returns default when neither direct nor *_FILE available', function () {
$env = new Environment([]);
$result = $env->get('DB_PASSWORD', 'default_value');
expect($result)->toBe('default_value');
});
it('returns null when file path does not exist', function () {
$env = new Environment(['DB_PASSWORD_FILE' => '/nonexistent/path']);
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
});
it('returns null when file path is not readable', function () {
$secretFile = $this->secretsDir . '/unreadable';
file_put_contents($secretFile, 'password');
chmod($secretFile, 0000); // Make unreadable
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
chmod($secretFile, 0600); // Restore for cleanup
});
it('handles multiple secrets from files', function () {
// Create multiple secret files
$dbPasswordFile = $this->secretsDir . '/db_password';
$apiKeyFile = $this->secretsDir . '/api_key';
file_put_contents($dbPasswordFile, 'db_secret');
file_put_contents($apiKeyFile, 'api_secret');
$env = new Environment([
'DB_PASSWORD_FILE' => $dbPasswordFile,
'API_KEY_FILE' => $apiKeyFile
]);
expect($env->get('DB_PASSWORD'))->toBe('db_secret')
->and($env->get('API_KEY'))->toBe('api_secret');
});
it('works with EnvKey enum', function () {
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'enum_password');
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
// Assuming DB_PASSWORD exists in EnvKey enum
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('enum_password');
});
it('handles empty secret files gracefully', function () {
$secretFile = $this->secretsDir . '/empty';
file_put_contents($secretFile, '');
$env = new Environment(['SECRET_FILE' => $secretFile]);
$result = $env->get('SECRET');
expect($result)->toBe(''); // Empty string, not null
});
it('ignores non-string *_FILE values', function () {
$env = new Environment(['DB_PASSWORD_FILE' => 12345]); // Invalid type
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
});
it('real-world Docker Swarm secrets scenario', function () {
// Simulate Docker Swarm secrets mount
$dbPasswordFile = $this->secretsDir . '/db_password';
$appKeyFile = $this->secretsDir . '/app_key';
file_put_contents($dbPasswordFile, "MyS3cur3P@ssw0rd\n"); // With newline
file_put_contents($appKeyFile, "base64:abcdef123456");
$env = new Environment([
'DB_HOST' => 'localhost', // Direct env var
'DB_PORT' => '3306', // Direct env var
'DB_PASSWORD_FILE' => $dbPasswordFile, // From Docker secret
'APP_KEY_FILE' => $appKeyFile, // From Docker secret
]);
// Verify all values work correctly
expect($env->get('DB_HOST'))->toBe('localhost')
->and($env->get('DB_PORT'))->toBe('3306')
->and($env->get('DB_PASSWORD'))->toBe('MyS3cur3P@ssw0rd')
->and($env->get('APP_KEY'))->toBe('base64:abcdef123456');
});
it('works with getRequired() for secrets from files', function () {
$secretFile = $this->secretsDir . '/required_secret';
file_put_contents($secretFile, 'required_value');
$env = new Environment(['REQUIRED_SECRET_FILE' => $secretFile]);
$result = $env->getRequired('REQUIRED_SECRET');
expect($result)->toBe('required_value');
});
it('throws exception with getRequired() when secret file not found', function () {
$env = new Environment(['REQUIRED_SECRET_FILE' => '/nonexistent/path']);
expect(fn() => $env->getRequired('REQUIRED_SECRET'))
->toThrow(App\Framework\Config\Exceptions\RequiredEnvironmentVariableException::class);
});
});

View File

@@ -0,0 +1,734 @@
<?php
declare(strict_types=1);
use App\Framework\Config\DockerSecretsResolver;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Exception\Config\EnvironmentVariableNotFoundException;
describe('Environment', function () {
beforeEach(function () {
// Create test environment variables
$this->testVariables = [
'APP_NAME' => 'TestApp',
'APP_ENV' => 'development',
'APP_DEBUG' => 'true',
'DB_HOST' => 'localhost',
'DB_PORT' => '3306',
'DB_NAME' => 'testdb',
'CACHE_TTL' => '3600',
'API_TIMEOUT' => '30.5',
'FEATURE_FLAGS' => 'feature1,feature2,feature3',
'EMPTY_STRING' => '',
'NULL_VALUE' => 'null',
'FALSE_VALUE' => 'false',
'ZERO_VALUE' => '0',
];
$this->dockerSecretsResolver = new DockerSecretsResolver();
$this->environment = new Environment($this->testVariables, $this->dockerSecretsResolver);
});
describe('get()', function () {
it('returns string value for existing key', function () {
$value = $this->environment->get('APP_NAME');
expect($value)->toBe('TestApp');
});
it('returns null for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT', 'default_value');
expect($value)->toBe('default_value');
});
it('returns empty string when value is empty', function () {
$value = $this->environment->get('EMPTY_STRING');
expect($value)->toBe('');
});
it('returns string "null" for null value', function () {
$value = $this->environment->get('NULL_VALUE');
expect($value)->toBe('null');
});
it('accepts EnvKey enum', function () {
$value = $this->environment->get(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
});
describe('getInt()', function () {
it('returns integer for numeric string', function () {
$value = $this->environment->getInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT', 9999);
expect($value)->toBe(9999);
});
it('returns zero for "0" string', function () {
$value = $this->environment->getInt('ZERO_VALUE');
expect($value)->toBe(0);
expect($value)->toBeInt();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
it('handles negative integers', function () {
$env = new Environment(['NEGATIVE' => '-42'], $this->dockerSecretsResolver);
$value = $env->getInt('NEGATIVE');
expect($value)->toBe(-42);
});
});
describe('getBool()', function () {
it('returns true for "true" string', function () {
$value = $this->environment->getBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('returns false for "false" string', function () {
$value = $this->environment->getBool('FALSE_VALUE');
expect($value)->toBeFalse();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT', true);
expect($value)->toBeTrue();
});
it('handles case-insensitive true values', function () {
$env = new Environment([
'BOOL1' => 'true',
'BOOL2' => 'True',
'BOOL3' => 'TRUE',
'BOOL4' => '1',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeTrue();
expect($env->getBool('BOOL2'))->toBeTrue();
expect($env->getBool('BOOL3'))->toBeTrue();
expect($env->getBool('BOOL4'))->toBeTrue();
});
it('handles case-insensitive false values', function () {
$env = new Environment([
'BOOL1' => 'false',
'BOOL2' => 'False',
'BOOL3' => 'FALSE',
'BOOL4' => '0',
'BOOL5' => '',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeFalse();
expect($env->getBool('BOOL2'))->toBeFalse();
expect($env->getBool('BOOL3'))->toBeFalse();
expect($env->getBool('BOOL4'))->toBeFalse();
expect($env->getBool('BOOL5'))->toBeFalse();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('getFloat()', function () {
it('returns float for decimal string', function () {
$value = $this->environment->getFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT', 99.99);
expect($value)->toBe(99.99);
});
it('converts integer string to float', function () {
$value = $this->environment->getFloat('DB_PORT');
expect($value)->toBe(3306.0);
expect($value)->toBeFloat();
});
it('handles negative floats', function () {
$env = new Environment(['NEGATIVE' => '-42.5'], $this->dockerSecretsResolver);
$value = $env->getFloat('NEGATIVE');
expect($value)->toBe(-42.5);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('getArray()', function () {
it('returns array from comma-separated string', function () {
$value = $this->environment->getArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT', ['default1', 'default2']);
expect($value)->toBe(['default1', 'default2']);
});
it('returns single-element array for non-comma value', function () {
$value = $this->environment->getArray('APP_NAME');
expect($value)->toBe(['TestApp']);
});
it('trims whitespace from array elements', function () {
$env = new Environment([
'WHITESPACE_ARRAY' => 'value1 , value2 , value3 ',
], $this->dockerSecretsResolver);
$value = $env->getArray('WHITESPACE_ARRAY');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('returns empty array for empty string', function () {
$value = $this->environment->getArray('EMPTY_STRING');
expect($value)->toBe(['']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->getArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
});
describe('getEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->getEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('returns null for non-existent key', function () {
$value = $this->environment->getEnum('NON_EXISTENT', AppEnvironment::class);
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getEnum(
'NON_EXISTENT',
AppEnvironment::class,
AppEnvironment::PRODUCTION
);
expect($value)->toBe(AppEnvironment::PRODUCTION);
});
it('handles case-insensitive enum matching', function () {
$env = new Environment([
'ENV1' => 'development',
'ENV2' => 'Development',
'ENV3' => 'DEVELOPMENT',
], $this->dockerSecretsResolver);
expect($env->getEnum('ENV1', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV2', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV3', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
});
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->getEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('handles backed enum with integer value', function () {
$env = new Environment(['STATUS' => '1'], $this->dockerSecretsResolver);
$value = $env->getEnum('STATUS', TestIntEnum::class);
expect($value)->toBe(TestIntEnum::ACTIVE);
});
});
describe('require()', function () {
it('returns value for existing key', function () {
$value = $this->environment->require('APP_NAME');
expect($value)->toBe('TestApp');
});
it('throws exception for non-existent key', function () {
$this->environment->require('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->require(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
it('throws exception with helpful message', function () {
try {
$this->environment->require('MISSING_VARIABLE');
$this->fail('Expected EnvironmentVariableNotFoundException');
} catch (EnvironmentVariableNotFoundException $e) {
expect($e->getMessage())->toContain('MISSING_VARIABLE');
expect($e->getMessage())->toContain('required');
}
});
});
describe('requireInt()', function () {
it('returns integer for existing numeric key', function () {
$value = $this->environment->requireInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('throws exception for non-existent key', function () {
$this->environment->requireInt('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
});
describe('requireBool()', function () {
it('returns boolean for existing boolean key', function () {
$value = $this->environment->requireBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('throws exception for non-existent key', function () {
$this->environment->requireBool('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('requireFloat()', function () {
it('returns float for existing numeric key', function () {
$value = $this->environment->requireFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('throws exception for non-existent key', function () {
$this->environment->requireFloat('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('requireArray()', function () {
it('returns array for existing comma-separated key', function () {
$value = $this->environment->requireArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('throws exception for non-existent key', function () {
$this->environment->requireArray('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->requireArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
});
describe('requireEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->requireEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('throws exception for non-existent key', function () {
$this->environment->requireEnum('NON_EXISTENT', AppEnvironment::class);
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->requireEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
});
describe('has()', function () {
it('returns true for existing key', function () {
$exists = $this->environment->has('APP_NAME');
expect($exists)->toBeTrue();
});
it('returns false for non-existent key', function () {
$exists = $this->environment->has('NON_EXISTENT');
expect($exists)->toBeFalse();
});
it('returns true for empty string value', function () {
$exists = $this->environment->has('EMPTY_STRING');
expect($exists)->toBeTrue();
});
it('accepts EnvKey enum', function () {
$exists = $this->environment->has(EnvKey::APP_NAME);
expect($exists)->toBeTrue();
});
});
describe('all()', function () {
it('returns all environment variables', function () {
$allVars = $this->environment->all();
expect($allVars)->toBe($this->testVariables);
expect($allVars)->toBeArray();
});
it('returns empty array for empty environment', function () {
$emptyEnv = new Environment([], $this->dockerSecretsResolver);
$allVars = $emptyEnv->all();
expect($allVars)->toBe([]);
});
});
describe('Docker Secrets Integration', function () {
it('resolves Docker secret when file exists', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_value_from_file');
$env = new Environment([
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBe('secret_value_from_file');
// Cleanup
unlink($secretPath);
});
it('returns null when Docker secret file does not exist', function () {
$env = new Environment([
'DB_PASSWORD_FILE' => '/nonexistent/path/to/secret',
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBeNull();
});
it('prioritizes direct variable over Docker secret', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_from_file');
$env = new Environment([
'DB_PASSWORD' => 'direct_value',
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
// Direct variable should win
expect($value)->toBe('direct_value');
// Cleanup
unlink($secretPath);
});
it('trims whitespace from Docker secret content', function () {
// Create temporary Docker secret file with whitespace
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, " secret_value_with_whitespace \n");
$env = new Environment([
'API_KEY_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('API_KEY');
expect($value)->toBe('secret_value_with_whitespace');
// Cleanup
unlink($secretPath);
});
it('handles multiple Docker secrets', function () {
// Create multiple secret files
$dbPasswordPath = '/tmp/db_password_secret';
$apiKeyPath = '/tmp/api_key_secret';
file_put_contents($dbPasswordPath, 'db_secret_123');
file_put_contents($apiKeyPath, 'api_secret_456');
$env = new Environment([
'DB_PASSWORD_FILE' => $dbPasswordPath,
'API_KEY_FILE' => $apiKeyPath,
], $this->dockerSecretsResolver);
expect($env->get('DB_PASSWORD'))->toBe('db_secret_123');
expect($env->get('API_KEY'))->toBe('api_secret_456');
// Cleanup
unlink($dbPasswordPath);
unlink($apiKeyPath);
});
});
describe('Edge Cases', function () {
it('handles keys with underscores', function () {
$env = new Environment([
'SOME_LONG_KEY_NAME' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('SOME_LONG_KEY_NAME');
expect($value)->toBe('value');
});
it('handles keys with numbers', function () {
$env = new Environment([
'VAR_123' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('VAR_123');
expect($value)->toBe('value');
});
it('handles very long values', function () {
$longValue = str_repeat('a', 10000);
$env = new Environment([
'LONG_VALUE' => $longValue,
], $this->dockerSecretsResolver);
$value = $env->get('LONG_VALUE');
expect($value)->toBe($longValue);
});
it('handles special characters in values', function () {
$env = new Environment([
'SPECIAL' => 'value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~',
], $this->dockerSecretsResolver);
$value = $env->get('SPECIAL');
expect($value)->toBe('value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~');
});
it('handles unicode characters', function () {
$env = new Environment([
'UNICODE' => 'Hello 世界 🌍',
], $this->dockerSecretsResolver);
$value = $env->get('UNICODE');
expect($value)->toBe('Hello 世界 🌍');
});
it('handles JSON strings as values', function () {
$jsonString = '{"key":"value","nested":{"foo":"bar"}}';
$env = new Environment([
'JSON_CONFIG' => $jsonString,
], $this->dockerSecretsResolver);
$value = $env->get('JSON_CONFIG');
expect($value)->toBe($jsonString);
expect(json_decode($value, true))->toBe([
'key' => 'value',
'nested' => ['foo' => 'bar'],
]);
});
it('handles URL strings as values', function () {
$env = new Environment([
'DATABASE_URL' => 'mysql://user:pass@localhost:3306/dbname',
], $this->dockerSecretsResolver);
$value = $env->get('DATABASE_URL');
expect($value)->toBe('mysql://user:pass@localhost:3306/dbname');
});
it('handles base64-encoded values', function () {
$base64Value = base64_encode('secret_data');
$env = new Environment([
'ENCODED_SECRET' => $base64Value,
], $this->dockerSecretsResolver);
$value = $env->get('ENCODED_SECRET');
expect($value)->toBe($base64Value);
expect(base64_decode($value))->toBe('secret_data');
});
});
describe('Type Coercion Edge Cases', function () {
it('handles non-numeric string for getInt', function () {
$env = new Environment([
'NOT_A_NUMBER' => 'abc',
], $this->dockerSecretsResolver);
$value = $env->getInt('NOT_A_NUMBER');
expect($value)->toBe(0); // PHP's (int) cast behavior
});
it('handles non-boolean string for getBool', function () {
$env = new Environment([
'NOT_A_BOOL' => 'maybe',
], $this->dockerSecretsResolver);
$value = $env->getBool('NOT_A_BOOL');
expect($value)->toBeFalse(); // Non-empty string that's not "true" or "1"
});
it('handles scientific notation for getFloat', function () {
$env = new Environment([
'SCIENTIFIC' => '1.5e3',
], $this->dockerSecretsResolver);
$value = $env->getFloat('SCIENTIFIC');
expect($value)->toBe(1500.0);
});
it('handles hexadecimal strings for getInt', function () {
$env = new Environment([
'HEX_VALUE' => '0xFF',
], $this->dockerSecretsResolver);
$value = $env->getInt('HEX_VALUE');
expect($value)->toBe(0); // String "0xFF" casts to 0
});
});
});
// Test enums for getEnum() testing
enum AppEnvironment: string
{
case DEVELOPMENT = 'development';
case STAGING = 'staging';
case PRODUCTION = 'production';
}
enum TestIntEnum: int
{
case INACTIVE = 0;
case ACTIVE = 1;
case PENDING = 2;
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ErrorHandling\ErrorHandlerRegistry;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerInterface;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\HandlerResult;
use App\Framework\Logging\Logger;
describe('ErrorHandlerManager', function () {
beforeEach(function () {
$this->registry = new ErrorHandlerRegistry();
$this->manager = new ErrorHandlerManager($this->registry);
});
it('executes handlers in priority order', function () {
$executionOrder = [];
$highPriorityHandler = new class ($executionOrder) implements ErrorHandlerInterface {
public function __construct(private array &$executionOrder) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->executionOrder[] = 'high';
return HandlerResult::create(
handled: true,
message: 'High priority handler'
);
}
public function getName(): string
{
return 'high_priority';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$lowPriorityHandler = new class ($executionOrder) implements ErrorHandlerInterface {
public function __construct(private array &$executionOrder) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->executionOrder[] = 'low';
return HandlerResult::create(
handled: true,
message: 'Low priority handler'
);
}
public function getName(): string
{
return 'low_priority';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($lowPriorityHandler, $highPriorityHandler);
$exception = new \Exception('Test');
$this->manager->handle($exception);
expect($executionOrder)->toBe(['high', 'low']);
});
it('stops propagation when handler marks as final', function () {
$called = [];
$finalHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'final';
return HandlerResult::create(
handled: true,
message: 'Final handler',
isFinal: true
);
}
public function getName(): string
{
return 'final_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$afterHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'after';
return HandlerResult::create(
handled: true,
message: 'After handler'
);
}
public function getName(): string
{
return 'after_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($finalHandler, $afterHandler);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($called)->toBe(['final']);
expect($result->handled)->toBeTrue();
});
it('skips handlers that cannot handle exception', function () {
$specificHandler = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return $exception instanceof \InvalidArgumentException;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Specific handler'
);
}
public function getName(): string
{
return 'specific';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$this->manager = $this->manager->register($specificHandler);
$exception = new \RuntimeException('Test');
$result = $this->manager->handle($exception);
expect($result->handled)->toBeFalse();
expect($result->results)->toBeEmpty();
});
it('continues chain even if handler throws exception', function () {
$called = [];
$failingHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'failing';
throw new \RuntimeException('Handler failed');
}
public function getName(): string
{
return 'failing';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$workingHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'working';
return HandlerResult::create(
handled: true,
message: 'Working handler'
);
}
public function getName(): string
{
return 'working';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($failingHandler, $workingHandler);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($called)->toBe(['failing', 'working']);
expect($result->handled)->toBeTrue();
});
it('aggregates results from multiple handlers', function () {
$handler1 = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Handler 1',
data: ['from' => 'handler1']
);
}
public function getName(): string
{
return 'handler1';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$handler2 = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Handler 2',
data: ['from' => 'handler2']
);
}
public function getName(): string
{
return 'handler2';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($handler1, $handler2);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($result->results)->toHaveCount(2);
expect($result->getMessages())->toBe(['Handler 1', 'Handler 2']);
$combinedData = $result->getCombinedData();
expect($combinedData)->toHaveKey('from');
});
});

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\ErrorHandling\Handlers\DatabaseErrorHandler;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\Logging\Logger;
describe('DatabaseErrorHandler', function () {
beforeEach(function () {
$this->logger = $this->createMock(Logger::class);
$this->handler = new DatabaseErrorHandler($this->logger);
});
it('handles DatabaseException', function () {
$exception = DatabaseException::fromContext(
'Connection failed',
\App\Framework\Exception\ExceptionContext::empty()
);
expect($this->handler->canHandle($exception))->toBeTrue();
$this->logger
->expects($this->once())
->method('error')
->with('Database error occurred', $this->anything());
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(500);
expect($result->data['error_type'])->toBe('database');
expect($result->data['retry_after'])->toBe(60);
});
it('handles PDOException', function () {
$exception = new \PDOException('SQLSTATE[HY000] [2002] Connection refused');
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(500);
});
it('does not handle non-database exceptions', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has HIGH priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::HIGH);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('database_error_handler');
});
});

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\FallbackErrorHandler;
use App\Framework\Logging\Logger;
describe('FallbackErrorHandler', function () {
beforeEach(function () {
$this->logger = $this->createMock(Logger::class);
$this->handler = new FallbackErrorHandler($this->logger);
});
it('handles any exception', function () {
$exception = new \RuntimeException('Any error');
expect($this->handler->canHandle($exception))->toBeTrue();
});
it('logs exception with full context', function () {
$exception = new \RuntimeException('Test error');
$this->logger
->expects($this->once())
->method('error')
->with('Unhandled exception', $this->callback(function ($context) use ($exception) {
return $context instanceof \App\Framework\Logging\ValueObjects\LogContext
&& $context->structured['exception_class'] === \RuntimeException::class
&& $context->structured['message'] === 'Test error'
&& isset($context->structured['file'])
&& isset($context->structured['line'])
&& isset($context->structured['trace']);
}));
$this->handler->handle($exception);
});
it('returns generic error message', function () {
$exception = new \RuntimeException('Detailed error');
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->message)->toBe('An unexpected error occurred');
expect($result->isFinal)->toBeTrue();
expect($result->statusCode)->toBe(500);
expect($result->data['error_type'])->toBe('unhandled');
expect($result->data['exception_class'])->toBe(\RuntimeException::class);
});
it('marks result as final', function () {
$exception = new \RuntimeException('Test');
$result = $this->handler->handle($exception);
expect($result->isFinal)->toBeTrue();
});
it('has LOWEST priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::LOWEST);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('fallback_error_handler');
});
});

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\HttpErrorHandler;
use App\Framework\Http\Exception\HttpException;
use App\Framework\Http\Status;
describe('HttpErrorHandler', function () {
beforeEach(function () {
$this->handler = new HttpErrorHandler();
});
it('handles HttpException', function () {
$exception = new HttpException(
'Not Found',
Status::NOT_FOUND,
headers: ['X-Custom' => 'value']
);
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->message)->toBe('Not Found');
expect($result->statusCode)->toBe(404);
expect($result->data['error_type'])->toBe('http');
expect($result->data['headers'])->toBe(['X-Custom' => 'value']);
});
it('handles HttpException with no headers', function () {
$exception = new HttpException(
'Bad Request',
Status::BAD_REQUEST
);
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(400);
expect($result->data['headers'])->toBe([]);
});
it('does not handle non-HttpException', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has NORMAL priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::NORMAL);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('http_error_handler');
});
});

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\ValidationErrorHandler;
use App\Framework\Validation\Exceptions\ValidationException;
describe('ValidationErrorHandler', function () {
beforeEach(function () {
$this->handler = new ValidationErrorHandler();
});
it('handles ValidationException', function () {
$validationResult = new \App\Framework\Validation\ValidationResult();
$validationResult->addErrors('email', ['Email is required', 'Email format is invalid']);
$validationResult->addErrors('password', ['Password must be at least 8 characters']);
$exception = new ValidationException($validationResult);
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(422);
expect($result->data)->toHaveKey('errors');
expect($result->data['errors'])->toBe($validationResult->getAll());
expect($result->data['error_type'])->toBe('validation');
});
it('does not handle non-ValidationException', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has CRITICAL priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::CRITICAL);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('validation_error_handler');
});
});

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

View File

@@ -1,232 +0,0 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Processors\ExceptionProcessor;
use App\Framework\Logging\ValueObjects\LogContext;
describe('ExceptionProcessor', function () {
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
$this->processor = new ExceptionProcessor();
});
describe('constructor', function () {
it('can be instantiated with default config', function () {
$processor = new ExceptionProcessor();
expect($processor instanceof ExceptionProcessor)->toBeTrue();
});
it('can be instantiated with custom config', function () {
$processor = new ExceptionProcessor(
includeStackTraces: false,
traceDepth: 5
);
expect($processor instanceof ExceptionProcessor)->toBeTrue();
});
});
describe('getPriority()', function () {
it('returns priority 15', function () {
expect($this->processor->getPriority())->toBe(15);
});
});
describe('getName()', function () {
it('returns name exception', function () {
expect($this->processor->getName())->toBe('exception');
});
});
describe('processRecord()', function () {
it('returns record unchanged when no exception present', function () {
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
expect($processed->getExtras())->toBeEmpty();
});
it('formats basic exception information', function () {
$exception = new RuntimeException('Test error', 123);
$record = new LogRecord(
message: 'Error occurred',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect($exceptionData)->toBeArray();
expect($exceptionData['class'])->toBe('RuntimeException');
expect($exceptionData['message'])->toBe('Test error');
expect($exceptionData['code'])->toBe(123);
expect(isset($exceptionData['file']))->toBeTrue();
expect(isset($exceptionData['line']))->toBeTrue();
});
it('includes stack trace by default', function () {
$exception = new Exception('Test exception');
$record = new LogRecord(
message: 'Error with trace',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect(isset($exceptionData['trace']))->toBeTrue();
expect($exceptionData['trace'])->toBeArray();
});
it('excludes stack trace when disabled', function () {
$processor = new ExceptionProcessor(includeStackTraces: false);
$exception = new Exception('Test exception');
$record = new LogRecord(
message: 'Error without trace',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect(isset($exceptionData['trace']))->toBeFalse();
});
it('handles nested exceptions', function () {
$innerException = new InvalidArgumentException('Inner error');
$outerException = new RuntimeException('Outer error', 0, $innerException);
$record = new LogRecord(
message: 'Nested exception',
context: LogContext::withData(['exception' => $outerException]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect($exceptionData['class'])->toBe('RuntimeException');
expect(isset($exceptionData['previous']))->toBeTrue();
expect($exceptionData['previous']['class'])->toBe('InvalidArgumentException');
expect($exceptionData['previous']['message'])->toBe('Inner error');
});
it('limits stack trace depth', function () {
$processor = new ExceptionProcessor(traceDepth: 3);
$exception = new Exception('Deep exception');
$record = new LogRecord(
message: 'Deep trace',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect(isset($exceptionData['trace']))->toBeTrue();
expect(count($exceptionData['trace']))->toBeLessThanOrEqual(3);
});
it('formats stack trace entries correctly', function () {
$exception = new Exception('Test exception');
$record = new LogRecord(
message: 'Error with trace',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
$trace = $exceptionData['trace'];
expect($trace)->toBeArray();
if (count($trace) > 0) {
$firstFrame = $trace[0];
expect(isset($firstFrame['file']))->toBeTrue();
expect(isset($firstFrame['line']))->toBeTrue();
}
});
it('includes function information in stack trace', function () {
$exception = new Exception('Test exception');
$record = new LogRecord(
message: 'Error',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
$trace = $exceptionData['trace'];
// At least one frame should have function info
$hasFunctionInfo = false;
foreach ($trace as $frame) {
if (isset($frame['function'])) {
$hasFunctionInfo = true;
break;
}
}
expect($hasFunctionInfo)->toBeTrue();
});
it('handles exception without previous exception', function () {
$exception = new Exception('Single exception');
$record = new LogRecord(
message: 'Single error',
context: LogContext::withData(['exception' => $exception]),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$processed = $this->processor->processRecord($record);
$exceptionData = $processed->getExtra('exception');
expect(isset($exceptionData['previous']))->toBeFalse();
});
});
describe('readonly behavior', function () {
it('is a final class', function () {
$reflection = new ReflectionClass(ExceptionProcessor::class);
expect($reflection->isFinal())->toBeTrue();
});
});
});

View File

@@ -13,7 +13,7 @@ use App\Framework\Redis\RedisConnectionInterface;
describe('DistributedJobCoordinator', function () {
beforeEach(function () {
$this->clock = new SystemClock();
// Mock Redis Connection
$this->redis = new class implements RedisConnectionInterface {
private array $data = [];

View File

@@ -0,0 +1,454 @@
<?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();
});
});
});

View File

@@ -0,0 +1,478 @@
<?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();
});
});
});