- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
431 lines
18 KiB
PHP
431 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Database\Exception\ConnectionFailedException;
|
|
use App\Framework\Database\Exception\ConstraintViolationException;
|
|
use App\Framework\Database\Exception\DeadlockException;
|
|
use App\Framework\Database\Exception\PoolExhaustedException;
|
|
use App\Framework\Database\Exception\QueryExecutionException;
|
|
use App\Framework\Database\Exception\QuerySyntaxException;
|
|
use App\Framework\Database\ValueObjects\SqlState;
|
|
use App\Framework\Exception\Core\DatabaseErrorCode;
|
|
|
|
describe('SQLSTATE Exception Classes', function () {
|
|
describe('ConnectionFailedException', function () {
|
|
it('creates exception for connection failure', function () {
|
|
$sqlState = new SqlState('08001');
|
|
$exception = ConnectionFailedException::cannotConnect(
|
|
'localhost',
|
|
'testdb',
|
|
$sqlState,
|
|
'Connection refused'
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('localhost')
|
|
->and($exception->getMessage())->toContain('testdb')
|
|
->and($exception->getSqlState())->toBe($sqlState)
|
|
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED)
|
|
->and($exception->getContext()->data['host'])->toBe('localhost')
|
|
->and($exception->getContext()->data['database'])->toBe('testdb');
|
|
});
|
|
|
|
it('creates exception for server rejection', function () {
|
|
$sqlState = new SqlState('08004');
|
|
$exception = ConnectionFailedException::serverRejectedConnection(
|
|
'db.example.com',
|
|
'dbuser',
|
|
$sqlState,
|
|
'Invalid credentials'
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('db.example.com')
|
|
->and($exception->getMessage())->toContain('dbuser')
|
|
->and($exception->getSqlState()->code)->toBe('08004')
|
|
->and($exception->getContext()->data['host'])->toBe('db.example.com')
|
|
->and($exception->getContext()->data['username'])->toBe('dbuser');
|
|
});
|
|
|
|
it('creates exception for connection timeout', function () {
|
|
$sqlState = new SqlState('HY000');
|
|
$exception = ConnectionFailedException::connectionTimeout(
|
|
'remote-db.com',
|
|
30,
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('30 seconds')
|
|
->and($exception->getContext()->data['timeout_seconds'])->toBe(30)
|
|
->and($exception->getContext()->data['host'])->toBe('remote-db.com');
|
|
});
|
|
|
|
it('creates exception for too many connections', function () {
|
|
$sqlState = new SqlState('HY000');
|
|
$exception = ConnectionFailedException::tooManyConnections(
|
|
'localhost',
|
|
100,
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('100')
|
|
->and($exception->getContext()->data['max_connections'])->toBe(100)
|
|
->and($exception->getContext()->data['host'])->toBe('localhost');
|
|
});
|
|
});
|
|
|
|
describe('ConstraintViolationException', function () {
|
|
it('creates exception for unique violation', function () {
|
|
$sqlState = new SqlState('23505');
|
|
$exception = ConstraintViolationException::uniqueViolation(
|
|
'users',
|
|
'email',
|
|
'test@example.com',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('email')
|
|
->and($exception->getMessage())->toContain('users')
|
|
->and($exception->getSqlState())->toBe($sqlState)
|
|
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION)
|
|
->and($exception->getContext()->data['table'])->toBe('users')
|
|
->and($exception->getContext()->data['column'])->toBe('email');
|
|
});
|
|
|
|
it('creates exception for foreign key violation', function () {
|
|
$sqlState = new SqlState('23503');
|
|
$exception = ConstraintViolationException::foreignKeyViolation(
|
|
'orders',
|
|
'user_id',
|
|
'users',
|
|
999,
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('orders')
|
|
->and($exception->getMessage())->toContain('users')
|
|
->and($exception->getContext()->data['table'])->toBe('orders')
|
|
->and($exception->getContext()->data['foreign_key'])->toBe('user_id')
|
|
->and($exception->getContext()->data['referenced_table'])->toBe('users');
|
|
});
|
|
|
|
it('creates exception for not null violation', function () {
|
|
$sqlState = new SqlState('23502');
|
|
$exception = ConstraintViolationException::notNullViolation(
|
|
'products',
|
|
'price',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('price')
|
|
->and($exception->getMessage())->toContain('products')
|
|
->and($exception->getContext()->data['table'])->toBe('products')
|
|
->and($exception->getContext()->data['column'])->toBe('price');
|
|
});
|
|
|
|
it('creates exception for composite unique violation', function () {
|
|
$sqlState = new SqlState('23505');
|
|
$exception = ConstraintViolationException::compositeUniqueViolation(
|
|
'user_permissions',
|
|
['user_id', 'permission_id'],
|
|
[123, 456],
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('user_id')
|
|
->and($exception->getMessage())->toContain('permission_id')
|
|
->and($exception->getContext()->data['columns'])->toBe(['user_id', 'permission_id'])
|
|
->and($exception->getContext()->debug['duplicate_values'])->toBe(['123', '456']);
|
|
});
|
|
});
|
|
|
|
describe('DeadlockException', function () {
|
|
it('creates exception for basic deadlock detection', function () {
|
|
$sqlState = new SqlState('40001');
|
|
$exception = DeadlockException::detected($sqlState);
|
|
|
|
expect($exception->getMessage())->toContain('Deadlock detected')
|
|
->and($exception->getSqlState())->toBe($sqlState)
|
|
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED)
|
|
->and($exception->getContext()->data['recoverable'])->toBeTrue()
|
|
->and($exception->getContext()->data['retry_recommended'])->toBeTrue();
|
|
});
|
|
|
|
it('creates exception with transaction details', function () {
|
|
$sqlState = new SqlState('40001');
|
|
$exception = DeadlockException::inTransaction(
|
|
'tx_12345',
|
|
$sqlState,
|
|
attemptNumber: 2
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('tx_12345')
|
|
->and($exception->getMessage())->toContain('attempt 2')
|
|
->and($exception->getContext()->data['transaction_id'])->toBe('tx_12345')
|
|
->and($exception->getContext()->data['attempt_number'])->toBe(2);
|
|
});
|
|
|
|
it('creates exception for max retries exceeded', function () {
|
|
$sqlState = new SqlState('40001');
|
|
$exception = DeadlockException::maxRetriesExceeded(
|
|
3,
|
|
$sqlState,
|
|
'tx_99999'
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('3 retry attempts')
|
|
->and($exception->getContext()->data['recoverable'])->toBeFalse()
|
|
->and($exception->getContext()->data['retry_recommended'])->toBeFalse()
|
|
->and($exception->getContext()->data['max_attempts'])->toBe(3);
|
|
});
|
|
|
|
it('calculates retry delay with exponential backoff', function () {
|
|
$delay1 = DeadlockException::getRetryDelay(1);
|
|
$delay2 = DeadlockException::getRetryDelay(2);
|
|
$delay3 = DeadlockException::getRetryDelay(3);
|
|
|
|
// Test basic properties of exponential backoff (jitter makes exact comparisons unreliable)
|
|
expect($delay1)->toBeGreaterThan(0)
|
|
->and($delay2)->toBeGreaterThan(0)
|
|
->and($delay3)->toBeGreaterThan(0)
|
|
->and($delay3)->toBeLessThanOrEqual(5000); // Max 5 seconds
|
|
});
|
|
|
|
it('determines retry eligibility', function () {
|
|
expect(DeadlockException::shouldRetry(1, 3))->toBeTrue()
|
|
->and(DeadlockException::shouldRetry(2, 3))->toBeTrue()
|
|
->and(DeadlockException::shouldRetry(3, 3))->toBeFalse()
|
|
->and(DeadlockException::shouldRetry(4, 3))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('QuerySyntaxException', function () {
|
|
it('creates exception for basic syntax error', function () {
|
|
$sqlState = new SqlState('42601');
|
|
$exception = QuerySyntaxException::forQuery(
|
|
'SELCT * FROM users',
|
|
$sqlState,
|
|
'Syntax error near "SELCT"'
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('Syntax error')
|
|
->and($exception->getSqlState())->toBe($sqlState)
|
|
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
|
|
});
|
|
|
|
it('creates exception for table not found', function () {
|
|
$sqlState = new SqlState('42P01');
|
|
$exception = QuerySyntaxException::tableNotFound(
|
|
'nonexistent_table',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('nonexistent_table')
|
|
->and($exception->getContext()->data['table_name'])->toBe('nonexistent_table');
|
|
});
|
|
|
|
it('creates exception for column not found', function () {
|
|
$sqlState = new SqlState('42703');
|
|
$exception = QuerySyntaxException::columnNotFound(
|
|
'invalid_column',
|
|
'users',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('users.invalid_column')
|
|
->and($exception->getContext()->data['column_name'])->toBe('invalid_column')
|
|
->and($exception->getContext()->data['table_name'])->toBe('users');
|
|
});
|
|
|
|
it('creates exception for insufficient privileges', function () {
|
|
$sqlState = new SqlState('42501');
|
|
$exception = QuerySyntaxException::insufficientPrivileges(
|
|
'DELETE',
|
|
'sensitive_data',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('DELETE')
|
|
->and($exception->getMessage())->toContain('sensitive_data')
|
|
->and($exception->getContext()->data['operation'])->toBe('DELETE')
|
|
->and($exception->getContext()->data['object_name'])->toBe('sensitive_data');
|
|
});
|
|
|
|
it('truncates long queries for safety', function () {
|
|
$longQuery = str_repeat('SELECT * FROM users WHERE id = 1 AND ', 20) . 'name = "test"';
|
|
$sqlState = new SqlState('42601');
|
|
$exception = QuerySyntaxException::forQuery($longQuery, $sqlState);
|
|
|
|
expect(strlen($exception->getContext()->data['query']))->toBeLessThanOrEqual(220); // 200 + " (truncated)"
|
|
});
|
|
});
|
|
|
|
describe('QueryExecutionException', function () {
|
|
it('creates exception for data type mismatch', function () {
|
|
$sqlState = new SqlState('22P02');
|
|
$exception = QueryExecutionException::forDataTypeMismatch(
|
|
'age',
|
|
'integer',
|
|
'not_a_number',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('age')
|
|
->and($exception->getMessage())->toContain('integer')
|
|
->and($exception->getContext()->data['column'])->toBe('age')
|
|
->and($exception->getContext()->data['expected_type'])->toBe('integer');
|
|
});
|
|
|
|
it('creates exception for data truncation', function () {
|
|
$sqlState = new SqlState('22001');
|
|
$exception = QueryExecutionException::forDataTruncation(
|
|
'description',
|
|
255,
|
|
500,
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('description')
|
|
->and($exception->getMessage())->toContain('255')
|
|
->and($exception->getContext()->data['max_length'])->toBe(255)
|
|
->and($exception->getContext()->data['actual_length'])->toBe(500);
|
|
});
|
|
|
|
it('creates exception for division by zero', function () {
|
|
$sqlState = new SqlState('22012');
|
|
$exception = QueryExecutionException::forDivisionByZero(
|
|
'total / count',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('Division by zero')
|
|
->and($exception->getContext()->data['expression'])->toBe('total / count');
|
|
});
|
|
|
|
it('sanitizes sensitive values in context', function () {
|
|
$sqlState = new SqlState('22P02');
|
|
$exception = QueryExecutionException::forDataTypeMismatch(
|
|
'password',
|
|
'string',
|
|
'my-secret-password-123',
|
|
$sqlState
|
|
);
|
|
|
|
// Value is stored in debug (not data) for security
|
|
// Note: sanitizeValue only truncates strings > 100 chars
|
|
expect($exception->getContext()->debug)->toHaveKey('actual_value');
|
|
expect($exception->getContext()->debug['actual_value'])->toBeString();
|
|
});
|
|
});
|
|
|
|
describe('PoolExhaustedException', function () {
|
|
it('creates exception when no connections available', function () {
|
|
$exception = PoolExhaustedException::noConnectionsAvailable(
|
|
currentConnections: 10,
|
|
maxConnections: 10,
|
|
connectionsInUse: 10
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('10/10')
|
|
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::POOL_EXHAUSTED)
|
|
->and($exception->getContext()->data['current_connections'])->toBe(10)
|
|
->and($exception->getContext()->data['max_connections'])->toBe(10)
|
|
->and($exception->getContext()->data['connections_in_use'])->toBe(10)
|
|
->and($exception->getContext()->data['free_connections'])->toBe(0)
|
|
->and($exception->getContext()->data['utilization_percent'])->toBe(100.0);
|
|
});
|
|
|
|
it('creates exception when all connections unhealthy', function () {
|
|
$exception = PoolExhaustedException::allConnectionsUnhealthy(
|
|
maxConnections: 5,
|
|
unhealthyConnections: 5
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('unhealthy')
|
|
->and($exception->getContext()->data['health_status'])->toBe('critical');
|
|
});
|
|
|
|
it('provides retry recommendations', function () {
|
|
$exception = PoolExhaustedException::withRetryRecommendation(
|
|
currentConnections: 8,
|
|
maxConnections: 10,
|
|
recommendedRetryDelayMs: 500
|
|
);
|
|
|
|
expect($exception->getMessage())->toContain('retry after 500ms')
|
|
->and($exception->getRetryAfter())->toBe(1) // Rounded up to 1 second
|
|
->and($exception->getContext()->data['recommended_retry_delay_ms'])->toBe(500);
|
|
});
|
|
|
|
it('provides context-aware recommended actions', function () {
|
|
$highUtilization = PoolExhaustedException::noConnectionsAvailable(
|
|
currentConnections: 100,
|
|
maxConnections: 100,
|
|
connectionsInUse: 100
|
|
);
|
|
|
|
$unhealthyConnections = PoolExhaustedException::allConnectionsUnhealthy(
|
|
maxConnections: 10,
|
|
unhealthyConnections: 10
|
|
);
|
|
|
|
expect($highUtilization->getRecommendedAction())->toContain('Increase max_connections');
|
|
expect($unhealthyConnections->getRecommendedAction())->toContain('Check database server health');
|
|
});
|
|
});
|
|
|
|
describe('Exception Integration', function () {
|
|
it('maintains SQLSTATE through exception hierarchy', function () {
|
|
$sqlState = new SqlState('23505');
|
|
$exception = ConstraintViolationException::uniqueViolation(
|
|
'users',
|
|
'email',
|
|
'test@example.com',
|
|
$sqlState
|
|
);
|
|
|
|
expect($exception->getSqlState())->toBe($sqlState)
|
|
->and($exception->getSqlState()->code)->toBe('23505')
|
|
->and($exception->getSqlState()->isUniqueViolation())->toBeTrue()
|
|
->and($exception->getSqlState()->isConstraintViolation())->toBeTrue();
|
|
});
|
|
|
|
it('includes proper error categorization', function () {
|
|
$connectionError = ConnectionFailedException::cannotConnect(
|
|
'localhost',
|
|
'testdb',
|
|
new SqlState('08001')
|
|
);
|
|
|
|
$syntaxError = QuerySyntaxException::tableNotFound(
|
|
'missing_table',
|
|
new SqlState('42P01')
|
|
);
|
|
|
|
$deadlock = DeadlockException::detected(new SqlState('40001'));
|
|
|
|
expect($connectionError->isCategory('DB'))->toBeTrue();
|
|
expect($syntaxError->isCategory('DB'))->toBeTrue();
|
|
expect($deadlock->isCategory('DB'))->toBeTrue();
|
|
|
|
expect($connectionError->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED);
|
|
expect($syntaxError->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
|
|
expect($deadlock->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED);
|
|
});
|
|
|
|
it('preserves previous exception chain', function () {
|
|
$pdoException = new \PDOException('Original PDO error');
|
|
$sqlState = new SqlState('08001');
|
|
|
|
$exception = ConnectionFailedException::cannotConnect(
|
|
'localhost',
|
|
'testdb',
|
|
$sqlState,
|
|
'Connection failed',
|
|
$pdoException
|
|
);
|
|
|
|
expect($exception->getPrevious())->toBe($pdoException);
|
|
});
|
|
});
|
|
});
|