feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
253
tests/Unit/Framework/Analytics/AnalyticsCollectorTest.php
Normal file
253
tests/Unit/Framework/Analytics/AnalyticsCollectorTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
tests/Unit/Framework/Api/ApiExceptionTest.php
Normal file
96
tests/Unit/Framework/Api/ApiExceptionTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
460
tests/Unit/Framework/Api/ApiRequestTraitTest.php
Normal file
460
tests/Unit/Framework/Api/ApiRequestTraitTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
tests/Unit/Framework/Attributes/ApiVersionAttributeTest.php
Normal file
136
tests/Unit/Framework/Attributes/ApiVersionAttributeTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
111
tests/Unit/Framework/Attributes/RouteTest.php
Normal file
111
tests/Unit/Framework/Attributes/RouteTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
28
tests/Unit/Framework/Attributes/SingletonTest.php
Normal file
28
tests/Unit/Framework/Attributes/SingletonTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
38
tests/Unit/Framework/Attributes/StaticPageTest.php
Normal file
38
tests/Unit/Framework/Attributes/StaticPageTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
55
tests/Unit/Framework/Auth/PasswordStrengthTest.php
Normal file
55
tests/Unit/Framework/Auth/PasswordStrengthTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
252
tests/Unit/Framework/Auth/PasswordValidationResultTest.php
Normal file
252
tests/Unit/Framework/Auth/PasswordValidationResultTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
645
tests/Unit/Framework/Config/DockerSecretsResolverTest.php
Normal file
645
tests/Unit/Framework/Config/DockerSecretsResolverTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
770
tests/Unit/Framework/Config/EncryptedEnvLoaderTest.php
Normal file
770
tests/Unit/Framework/Config/EncryptedEnvLoaderTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
496
tests/Unit/Framework/Config/EnvFileParserTest.php
Normal file
496
tests/Unit/Framework/Config/EnvFileParserTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
185
tests/Unit/Framework/Config/EnvironmentDockerSecretsTest.php
Normal file
185
tests/Unit/Framework/Config/EnvironmentDockerSecretsTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
734
tests/Unit/Framework/Config/EnvironmentTest.php
Normal file
734
tests/Unit/Framework/Config/EnvironmentTest.php
Normal 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;
|
||||
}
|
||||
320
tests/Unit/Framework/ErrorHandling/ErrorHandlerManagerTest.php
Normal file
320
tests/Unit/Framework/ErrorHandling/ErrorHandlerManagerTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
194
tests/Unit/Framework/LiveComponents/Polling/PollServiceTest.php
Normal file
194
tests/Unit/Framework/LiveComponents/Polling/PollServiceTest.php
Normal 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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user