Files
michaelschiemer/tests/Unit/Framework/Database/Exception/SqlStateExceptionsTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- 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.
2025-10-25 19:18:37 +02:00

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